Files
silo-server/internal/ebookconvert/converter.go
e99079abf8 Server-side Kindle→EPUB conversion (mobi/azw/azw3) for in-app reading (#171)
* Kindle->EPUB conversion: design + proven wasm build pipeline

Server-side MOBI/AZW/AZW3 -> EPUB conversion so the Android in-app reader
can render Kindle-family ebooks. Conversion runs in-process via libmobi's
mobitool compiled to wasm32-wasi, executed by wazero (pure Go) -- no cgo,
no external binary, arch-independent, sandboxed untrusted input.

This commit lands the design + the validated build artifact (spike done):
- docs/.../2026-06-17-kindle-epub-conversion-design.md (Codex-reviewed;
  9 review fixes folded in: failure contract, strong cache key + negative
  cache, wazero command-module specifics, FS-sandbox tightening,
  double-gated capability, serve headers, .wasm guardrails).
- tools/mobitool-wasm/{Dockerfile,README.md}: reproducible build of
  mobitool.wasm (wasi-sdk 25, libmobi 9062742, zlib 1.3.1->wasm), with a
  smoke-conversion gate. Build proven on native amd64.
- internal/ebookconvert/mobitool.wasm (+ .sha256): canonical artifact,
  built on amd64. go:embed target for the converter package (next).

Spike proven on amd64: -e EPUB path works with --with-libxml2=no (internal
xmlwriter); converts MOBI6/KF8/HUFF-CDIC/unicode -> well-formed EPUB;
verified end-to-end under wazero (WASI preopen + argv + _start). Build
gotcha: link libmobi against real (wasm) zlib, not --with-zlib=no, to avoid
miniz duplicate-symbol clash with mobitool's zip miniz. DRM gotcha:
mobitool prints "Document is encrypted" to stdout but exits 0 -> detect via
stdout + output validation, not exit code.

Not yet implemented: internal/ebookconvert Go package (wazero harness +
cache + singleflight), read-handler wiring, admin flag, client capability.
v1-scope proposal required before PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ebookconvert: converter core + cache (Codex-reviewed)

internal/ebookconvert: in-process MOBI/AZW/AZW3 -> EPUB via the embedded
mobitool.wasm on wazero. Converter compiles the module once and instantiates
per conversion (isolated). Cache adds on-disk, singleflighted, size-bounded,
negative-cached conversion keyed by file identity + module fingerprint.

18 tests pass (DRM-free->valid EPUB, DRM->ErrDRMProtected + no output,
oversize/corrupt/missing/timeout/cancel/after-close, 6/8-way concurrent,
EPUB structural validation incl. stored-mimetype + container rootfile,
cache miss/hit/key-change/singleflight/eviction/negative-cache).

Codex review fixes folded in:
- timeout/cancel classified before generic nonzero exit (WithCloseOnContextDone
  surfaces sys.ExitError special codes); no more bogus "exit <huge>".
- DRM detection scoped to known mobitool diagnostic LINES (Document is
  encrypted / DRM key not found / Invalid DRM pid / DRM expired / DRM support
  not included) -> no false-positive on book text; Print Replica -> clear fail.
- WithMemoryLimitPages cap; capped stdout/stderr writers; MaxOutputBytes.
- read-only fs.FS input mount + dedicated writable out dir; documented that
  FS isolation ultimately relies on running as a non-root user (memory-safety
  is the WASM boundary). validateEpub now requires STORED mimetype + verifies
  the container.xml OPF rootfile exists. Atomic moveFile. Closed-guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ebookconvert: wire Kindle->EPUB into the read handler + capability endpoint

Server now transparently serves Kindle-family ebooks as EPUB when the admin
flag ebook.kindle_conversion_enabled is on and the WASM converter initialized.

- handlers.EbookConversion (converter + per-request flag predicate) on the read
  handler; HandleReadFile -> h.serveEbook. Kindle + enabled -> cached EPUB with
  X-Silo-Ebook-Conversion: converted, epub MIME, ETag = exact conversion cache
  key, must-revalidate. Failure (DRM/corrupt/oversize/unservable) -> raw
  original + X-Silo-Ebook-Conversion: failed + no-store, so the client opens
  externally. Context cancel propagates (not a conversion verdict).
- GET /api/v1/ebooks/capability advertises {enabled, source_formats,
  served_format, header contract}; enabled only when flag on AND converter
  wired (double gate) so the Android client can decide whether to flip
  mobi/azw/azw3 to in-app.
- router: buildEbookConversion compiles the module once at startup (feature off
  if it fails), cache dir is a sibling of TranscodeDir, flag read per request.

Codex review fixes folded in: ETag derived from the exact SourceKey cache key
(id+size+mtime+oshash+module version), not a weaker hash; no-store on the raw
fallback; open/stat failure of a produced EPUB falls back to raw per the
contract instead of 500. 10 handler tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ebookconvert): harden conversion cache, HEAD path, and artifact verification

Addresses adversarial review + CodeRabbit findings on the Kindle->EPUB feature.

Correctness:
- Stop poisoning the negative cache on transient timeouts. Introduce
  ErrConversionTimedOut (distinct, non-wrapping ErrConversionFailed); classify
  the per-call timeout as transient and propagate a caller's cancel/deadline
  verbatim instead of reclassifying it as a conversion failure. remember() now
  only caches deterministic verdicts (DRM / failed), so a one-off timeout under
  load no longer wedges a convertible book onto raw-fallback for 6h.
- Detach the singleflight conversion from any single caller's context (DoChan +
  context.WithoutCancel), so one caller cancelling no longer aborts the shared
  work for the others; the cache is still populated for the next reader.
- enforceBudget never evicts the entry it is about to return, and skips other
  conversions' in-flight "converting-*" temp files.
- Cache hits refresh mtime so the mtime-ordered budget eviction is a real LRU,
  not FIFO.

Read path:
- HEAD is now cache-only via Cache.Lookup: a hit serves real converted headers,
  a negatively-cached source serves the failed contract, a miss advertises the
  converted representation cheaply without triggering a (minute-long, ~1 GiB)
  conversion. The GET still delivers the body + authoritative verdict.
- The admin flag is read through a short-TTL predicate so the read path and the
  capability endpoint no longer hit the DB per request.

Artifact / build:
- Add an in-code provenance test (embedded mobitool.wasm matches its recorded
  sha256) and a self-hosted CI job that runs the ebookconvert smoke conversions
  + provenance check, so the committed wasm can't silently rot.
- Pin + checksum-verify wasmtime in the build Dockerfile (drop curl|bash).

Docs: correct the design doc cache-key + setting-name descriptions, document the
HEAD/timeout/LRU semantics and resource limits, note DRM-marker brittleness, and
fix the README markdown table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: remove ebookconvert workflow

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-17 13:09:55 -04:00

469 lines
14 KiB
Go

package ebookconvert
import (
"archive/zip"
"bytes"
"context"
_ "embed"
"encoding/xml"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
"github.com/tetratelabs/wazero/sys"
)
// mobitoolWasm is libmobi's mobitool compiled to wasm32-wasi.
// Built by tools/mobitool-wasm/Dockerfile; provenance in mobitool.wasm.sha256.
//
//go:embed mobitool.wasm
var mobitoolWasm []byte
// Guest layout. Input is mounted READ-ONLY (confined fs.FS, no `..` escape);
// output is a dedicated writable dir. NOTE: a writable wazero dir mount is not
// a hard jail (`..` traversal is possible within the server user's reach), so
// the WASM sandbox is a *memory-safety* boundary — filesystem isolation relies
// on running the server as a constrained, non-root user/container. See the
// design doc's "Filesystem sandboxing" section.
const (
guestInDir = "/in"
guestOutDir = "/out"
guestInputName = "in.mobi" // mobitool sniffs content; extension is cosmetic
guestEpubName = "in.epub" // mobitool derives this from the input basename
)
// mobitool diagnostic lines that mean the source is encrypted/DRM'd. Matched as
// substrings against individual output lines (not the whole blob) so book
// metadata/text containing "DRM" cannot false-positive. "Document is encrypted"
// is distinct from mobitool's "Document is not encrypted, ignoring PID/serial".
var drmMarkers = []string{
"Document is encrypted",
"DRM key not found",
"Invalid DRM pid",
"DRM expired",
"DRM support not included",
}
// Print Replica = fixed-layout (image) Kindle book; mobitool ignores -e and
// produces no EPUB. Detected for a clear failure message.
const printReplicaMarker = "Print Replica"
// Options configures a Converter. Zero values fall back to sane defaults.
type Options struct {
MaxSourceBytes int64 // reject larger sources before invoking the converter
MaxOutputBytes int64 // reject a produced EPUB larger than this
Timeout time.Duration // bounds a single conversion
MaxConcurrent int // caps simultaneous conversions
MaxMemoryPages uint32 // wasm linear-memory cap (64 KiB/page)
MaxLogBytes int // cap on captured stdout+stderr each
WorkRoot string // parent for per-conversion scratch dirs ("" = os.TempDir)
}
const (
DefaultMaxSourceBytes = 256 << 20 // 256 MiB
DefaultMaxOutputBytes = 512 << 20 // 512 MiB
DefaultTimeout = 60 * time.Second
DefaultConcurrency = 2
DefaultMaxMemoryPages = 16384 // 1 GiB
DefaultMaxLogBytes = 64 << 10
)
func (o *Options) applyDefaults() {
if o.MaxSourceBytes <= 0 {
o.MaxSourceBytes = DefaultMaxSourceBytes
}
if o.MaxOutputBytes <= 0 {
o.MaxOutputBytes = DefaultMaxOutputBytes
}
if o.Timeout <= 0 {
o.Timeout = DefaultTimeout
}
if o.MaxConcurrent <= 0 {
o.MaxConcurrent = DefaultConcurrency
}
if o.MaxMemoryPages == 0 {
o.MaxMemoryPages = DefaultMaxMemoryPages
}
if o.MaxLogBytes <= 0 {
o.MaxLogBytes = DefaultMaxLogBytes
}
}
// Converter converts Kindle-family ebooks to EPUB in-process via wazero.
// Safe for concurrent use. The WASM module is compiled once; each conversion
// instantiates a fresh, isolated module.
type Converter struct {
runtime wazero.Runtime
compiled wazero.CompiledModule
opts Options
sem chan struct{}
closed atomic.Bool
}
// NewConverter compiles the embedded module and returns a ready Converter.
// On failure it returns ErrUnavailable; callers must not advertise the
// conversion capability when this fails.
func NewConverter(ctx context.Context, opts Options) (*Converter, error) {
opts.applyDefaults()
cfg := wazero.NewRuntimeConfig().
WithCloseOnContextDone(true).
WithMemoryLimitPages(opts.MaxMemoryPages)
r := wazero.NewRuntimeWithConfig(ctx, cfg)
if _, err := wasi_snapshot_preview1.Instantiate(ctx, r); err != nil {
_ = r.Close(ctx)
return nil, fmt.Errorf("%w: instantiate wasi: %v", ErrUnavailable, err)
}
compiled, err := r.CompileModule(ctx, mobitoolWasm)
if err != nil {
_ = r.Close(ctx)
return nil, fmt.Errorf("%w: compile module: %v", ErrUnavailable, err)
}
return &Converter{
runtime: r,
compiled: compiled,
opts: opts,
sem: make(chan struct{}, opts.MaxConcurrent),
}, nil
}
// Close releases the runtime. Convert returns ErrUnavailable afterward.
func (c *Converter) Close(ctx context.Context) error {
if c == nil || c.runtime == nil {
return nil
}
c.closed.Store(true)
return c.runtime.Close(ctx)
}
// Convert reads the MOBI/AZW/AZW3 at srcPath and writes a validated EPUB to
// dstPath. Returns ErrDRMProtected, ErrConversionFailed (deterministic),
// ErrConversionTimedOut (transient — the per-call timeout fired),
// ErrSourceTooLarge, ErrUnavailable, or the caller's context error on
// cancellation/deadline.
func (c *Converter) Convert(ctx context.Context, srcPath, dstPath string) error {
if c.closed.Load() {
return ErrUnavailable
}
info, statErr := os.Stat(srcPath)
if statErr != nil {
return fmt.Errorf("%w: stat source: %v", ErrConversionFailed, statErr)
}
if info.Size() > c.opts.MaxSourceBytes {
return ErrSourceTooLarge
}
select {
case c.sem <- struct{}{}:
defer func() { <-c.sem }()
case <-ctx.Done():
return ctx.Err()
}
// Per-conversion scratch: in/ (read-only mount) and out/ (writable mount).
scratch, err := os.MkdirTemp(c.opts.WorkRoot, "ebookconvert-")
if err != nil {
return fmt.Errorf("%w: scratch: %v", ErrConversionFailed, err)
}
defer os.RemoveAll(scratch)
inDir := filepath.Join(scratch, "in")
outDir := filepath.Join(scratch, "out")
if err := os.MkdirAll(inDir, 0o755); err != nil {
return fmt.Errorf("%w: in dir: %v", ErrConversionFailed, err)
}
if err := os.MkdirAll(outDir, 0o755); err != nil {
return fmt.Errorf("%w: out dir: %v", ErrConversionFailed, err)
}
if err := copyFile(srcPath, filepath.Join(inDir, guestInputName)); err != nil {
return fmt.Errorf("%w: stage input: %v", ErrConversionFailed, err)
}
runCtx, cancel := context.WithTimeout(ctx, c.opts.Timeout)
defer cancel()
stdout := &cappedWriter{max: c.opts.MaxLogBytes}
stderr := &cappedWriter{max: c.opts.MaxLogBytes}
fsConfig := wazero.NewFSConfig().
WithFSMount(os.DirFS(inDir), guestInDir). // read-only, confined
WithDirMount(outDir, guestOutDir) // writable
modConfig := wazero.NewModuleConfig().
WithArgs("mobitool", "-e", "-o", guestOutDir, guestInDir+"/"+guestInputName).
WithFSConfig(fsConfig).
WithStdout(stdout).
WithStderr(stderr).
WithSysWalltime().
WithName("")
mod, instErr := c.runtime.InstantiateModule(runCtx, c.compiled, modConfig)
if mod != nil {
_ = mod.Close(runCtx)
}
combined := stdout.String() + "\n" + stderr.String()
if err := classifyRunError(ctx, runCtx, instErr, c.opts.Timeout, combined); err != nil {
return err
}
// DRM and unconvertible layouts are reported on stdout with exit 0.
if matchesAnyLine(combined, drmMarkers) {
return ErrDRMProtected
}
if lineContains(combined, printReplicaMarker) {
return fmt.Errorf("%w: print-replica (fixed-layout) book is not convertible", ErrConversionFailed)
}
producedEpub := filepath.Join(outDir, guestEpubName)
if fi, err := os.Stat(producedEpub); err != nil {
return fmt.Errorf("%w: no EPUB produced: %s", ErrConversionFailed, trim(combined))
} else if fi.Size() > c.opts.MaxOutputBytes {
return fmt.Errorf("%w: output EPUB exceeds %d bytes", ErrConversionFailed, c.opts.MaxOutputBytes)
}
if err := validateEpub(producedEpub); err != nil {
return fmt.Errorf("%w: %v", ErrConversionFailed, err)
}
if err := moveFile(producedEpub, dstPath); err != nil {
return fmt.Errorf("%w: deliver output: %v", ErrConversionFailed, err)
}
return nil
}
// classifyRunError maps a wazero run error to our error taxonomy. Context
// errors (timeout/cancel) MUST be checked before a generic nonzero exit code,
// because WithCloseOnContextDone surfaces them as *sys.ExitError with special
// codes that would otherwise read as an absurd "exit code".
//
// Ordering matters: the *caller's* context is checked first so a request-level
// cancel OR deadline is propagated verbatim (never reclassified as a conversion
// verdict — that would both mislead upstream cancellation and poison the
// negative cache). Only once the caller's context is healthy do we attribute a
// deadline to our own per-call timeout, which is a transient ErrConversionTimedOut.
func classifyRunError(parent, run context.Context, instErr error, timeout time.Duration, combined string) error {
if instErr == nil {
return nil
}
// Caller's context ended (cancel or deadline) → propagate it verbatim.
if parent.Err() != nil {
return parent.Err()
}
// Our per-call timeout fired (caller's context is still healthy).
if errors.Is(run.Err(), context.DeadlineExceeded) ||
errors.Is(instErr, context.DeadlineExceeded) {
return fmt.Errorf("%w: after %s", ErrConversionTimedOut, timeout)
}
if errors.Is(instErr, context.Canceled) {
return context.Canceled
}
var exitErr *sys.ExitError
if errors.As(instErr, &exitErr) {
switch exitErr.ExitCode() {
case sys.ExitCodeDeadlineExceeded:
return fmt.Errorf("%w: after %s", ErrConversionTimedOut, timeout)
case sys.ExitCodeContextCanceled:
return context.Canceled
case 0:
return nil // normal exit; output checks follow
default:
return fmt.Errorf("%w: mobitool exit %d: %s", ErrConversionFailed, exitErr.ExitCode(), trim(combined))
}
}
return fmt.Errorf("%w: run: %v", ErrConversionFailed, instErr)
}
// validateEpub confirms a well-formed EPUB OCF container: a zip whose first
// entry is a STORED (uncompressed) "mimetype" holding exactly
// application/epub+zip, with a META-INF/container.xml that names an OPF
// rootfile present in the archive.
func validateEpub(path string) error {
zr, err := zip.OpenReader(path)
if err != nil {
return fmt.Errorf("open epub: %w", err)
}
defer zr.Close()
if len(zr.File) == 0 {
return errors.New("empty epub")
}
first := zr.File[0]
if first.Name != "mimetype" {
return fmt.Errorf("first entry is %q, want mimetype", first.Name)
}
if first.Method != zip.Store {
return errors.New("mimetype is not stored (uncompressed)")
}
rc, err := first.Open()
if err != nil {
return fmt.Errorf("read mimetype: %w", err)
}
mt, err := io.ReadAll(io.LimitReader(rc, 64))
rc.Close()
if err != nil {
return fmt.Errorf("read mimetype: %w", err)
}
if string(mt) != "application/epub+zip" {
return fmt.Errorf("mimetype is %q", string(mt))
}
names := make(map[string]bool, len(zr.File))
for _, f := range zr.File {
names[f.Name] = true
}
if !names["META-INF/container.xml"] {
return errors.New("missing META-INF/container.xml")
}
rootfile, err := opfRootfile(zr)
if err != nil {
return err
}
if !names[rootfile] {
return fmt.Errorf("OPF rootfile %q missing from archive", rootfile)
}
return nil
}
func opfRootfile(zr *zip.ReadCloser) (string, error) {
for _, f := range zr.File {
if f.Name != "META-INF/container.xml" {
continue
}
rc, err := f.Open()
if err != nil {
return "", fmt.Errorf("open container.xml: %w", err)
}
data, err := io.ReadAll(io.LimitReader(rc, 64<<10))
rc.Close()
if err != nil {
return "", fmt.Errorf("read container.xml: %w", err)
}
var c struct {
Rootfiles []struct {
FullPath string `xml:"full-path,attr"`
} `xml:"rootfiles>rootfile"`
}
if err := xml.Unmarshal(data, &c); err != nil {
return "", fmt.Errorf("parse container.xml: %w", err)
}
if len(c.Rootfiles) == 0 || c.Rootfiles[0].FullPath == "" {
return "", errors.New("container.xml has no rootfile")
}
return c.Rootfiles[0].FullPath, nil
}
return "", errors.New("missing META-INF/container.xml")
}
// matchesAnyLine reports whether any line of s contains any of the markers.
func matchesAnyLine(s string, markers []string) bool {
for _, line := range strings.Split(s, "\n") {
for _, m := range markers {
if strings.Contains(line, m) {
return true
}
}
}
return false
}
func lineContains(s, marker string) bool {
for _, line := range strings.Split(s, "\n") {
if strings.Contains(line, marker) {
return true
}
}
return false
}
// cappedWriter buffers up to max bytes and silently drops the rest, always
// reporting a full write so the guest is never blocked on a full pipe.
type cappedWriter struct {
buf bytes.Buffer
max int
truncated bool
}
func (w *cappedWriter) Write(p []byte) (int, error) {
if room := w.max - w.buf.Len(); room > 0 {
if room >= len(p) {
w.buf.Write(p)
} else {
w.buf.Write(p[:room])
w.truncated = true
}
} else if len(p) > 0 {
w.truncated = true
}
return len(p), nil
}
func (w *cappedWriter) String() string { return w.buf.String() }
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
// moveFile delivers src to dst atomically: rename when possible, else copy to a
// temp file in dst's directory, fsync, and rename — never leaving a partial dst.
func moveFile(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if err := os.Rename(src, dst); err == nil {
return nil
}
tmp, err := os.CreateTemp(filepath.Dir(dst), ".move-*")
if err != nil {
return err
}
tmpName := tmp.Name()
in, err := os.Open(src)
if err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
_, copyErr := io.Copy(tmp, in)
in.Close()
if copyErr == nil {
copyErr = tmp.Sync()
}
if cerr := tmp.Close(); copyErr == nil {
copyErr = cerr
}
if copyErr != nil {
os.Remove(tmpName)
return copyErr
}
if err := os.Rename(tmpName, dst); err != nil {
os.Remove(tmpName)
return err
}
return os.Remove(src)
}
func trim(s string) string {
const max = 500
s = strings.TrimSpace(s)
if len(s) > max {
return s[:max] + "…"
}
return s
}