Skip to content

Deck Package Format

Claude edited this page Oct 26, 2026 · 4 revisions

Deck Package Format

English · Español

A .pbdeck file is a ZIP that carries one deck together with every image it uses, so it can be moved to another computer (or backed up) without losing anything. Code: src-tauri/src/package.rs (export, import, blobs_del_deck), commands deck_export_package / deck_import_package in src-tauri/src/commands/library.rs.

The format is deliberately boring: a JSON manifest plus the original image files named by their SHA-256. Import distrusts everything in the file.

Contents of the ZIP

x.pbdeck
├── manifest.json                 (Deflate-compressed)
└── blobs/
    ├── <sha256>.jpg|png|webp     (stored, not recompressed)
    └── ...                       (one entry per distinct image)

manifest.json

Serialized from the private Manifiesto struct (pretty-printed JSON):

Field Type Meaning
format string Must equal FORMATO = "proxy-builder-deck".
version u32 Package version, currently VERSION = 2.
deck Deck The whole Deck-Model value, as JSON (cards, settings, default back, decklistText, marks, designs...).
colecciones Vec<Collection> The designer collections referenced by the designs in the deck (design.collection). #[serde(default)].
biblioteca optional Library data: etiquetas ([{nombre, color}]), notas (string), idioma (optional language override), listas (the game table's game lists, [ListaDeJuego] with copy ids of this deck; omitted when empty). #[serde(default)]; every inner field also defaults.

Short example of the shape (abridged):

{ "format": "proxy-builder-deck", "version": 2,
  "deck": { "id": "...", "name": "...", "cards": [ ... ], "settings": { ... } },
  "colecciones": [],
  "biblioteca": { "etiquetas": [{ "nombre": "Tournament", "color": "red" }],
                  "notas": "", "idioma": "es" } }

Which blobs are packed

blobs_del_deck(deck) returns the set (a BTreeSet, so order is stable) of hashes of: the default back, every card front, every Dfc/Custom back, and every designer image (CardDesign::blobs(): art, "crop" source blobs, own emblem, "before" front and back). escribir adds the emblem of each exported collection. Each is written once as blobs/<hash>.<ext> where ext comes from the stored file.

What does not travel

Not included Consequence on the other computer
Imported fonts and frame packs (app_data_dir/fuentes, marcos/) Designs fall back to the built-in ones ("the house" fonts and packs).
Favourite flag, chosen cover card Not part of Biblioteca.
Card ids Regenerated on import.
Scryfall metadata cache Not needed: CardMeta is inside each card.

Version compatibility

Package version What it adds
1 Deck + images.
2 Designer data (DeckCard.design) and the collections they use (colecciones).

biblioteca (tags, notes, and later the language override and the game lists) was added without bumping version: it is optional, and serde ignores unknown fields in older apps, which simply do not see it. The same pattern is used for later Deck fields (see Deck-Model).

Import rules, in order (package::import):

  1. A ZIP that cannot be read is InvalidInput (motivo = zip); an IO error is Io.
  2. No manifest.json, or one that does not deserialize into Manifiesto: InvalidInput, motivo = manifiesto. Note the parse happens while scanning entries, before the version check, so a package from a future app whose deck JSON no longer fits the current types reports manifiesto, not "unsupported". Inferred from the order in the code.
  3. format != "proxy-builder-deck": InvalidInput, motivo = formato.
  4. version > VERSION: Unsupported with params.version. This is how a 1.x app rejects a v2 package (it would otherwise lose the designs on its next save).
  5. A package with a lower version imports normally; missing newer fields take their serde defaults.

The package version is independent of DECK_SCHEMA_VERSION (both happen to be 2 now). The deck's own schema_version inside the manifest is not checked on import; decks::save stamps the current value.

Export flow

flowchart TD
    A["menu-exportar.tsx / library-screen.tsx: guardarAhora()"] --> B["guardarParaExportar(): pick .pbdeck path"]
    B --> C["deck_export_package(id, path)"]
    C --> D["decks::open: deck from the database"]
    D --> E["package::export"]
    E --> F["escribir: manifest + collections + tags/notes/language"]
    F --> G["for each hash in blobs_del_deck + emblems: BlobStore::locate, read file"]
    G --> H["ZIP built in memory (Cursor)"]
    H --> I["escribir_atomico: .part then rename"]
    I --> J["returns size in bytes to the UI"]
Loading

Details:

  • The screen first flushes pending edits (guardarAhora()), then asks for a destination through the shared dialog helper guardarParaExportar (src/lib/dialogos.ts, which remembers the last export folder), then calls the command. Two entry points: the "..." menu next to "Export PDF" (src/features/export/menu-exportar.tsx) for the open deck, and the library screen's per-deck action (src/features/library/library-screen.tsx).
  • The export reads the deck from the database (decks::open), not from the screen, which is why the flush comes first.
  • The ZIP is built fully in memory and written atomically; a crash never leaves a partial .pbdeck. A missing blob file is NotFound (params.blob).
  • Deterministic output: entries use a fixed timestamp (zip::DateTime::default()), blobs come in sorted order, so exporting the same deck twice yields byte-identical files (test el_mismo_deck_da_el_mismo_fichero).
  • Images are Stored, the manifest is Deflated: images are already compressed.

Import flow

flowchart TD
    A["library-screen.tsx: abrirParaImportar(.pbdeck)"] --> B["deck_import_package(path)"]
    B --> C["package::import(reader)"]
    C --> D["for each ZIP entry: leer_entrada"]
    D --> E{"name"}
    E -- "manifest.json" --> F["parse Manifiesto"]
    E -- "blobs/xxxx.ext" --> G["validate name, SHA-256 must match"]
    E -- other --> H["ignored"]
    F --> I["check format and version"]
    G --> I
    I --> J["every image the deck needs is in the zip or already local?"]
    J --> K["BlobStore::put_bytes for each image (origin taken from the deck)"]
    K --> L["collections: add those whose id does not exist locally"]
    L --> M["new deck id + new card ids, rename if the name exists"]
    M --> N["decks::save"]
    N --> O["tags, notes, language, game lists"]
    O --> P["returns the new Deck, library reloads"]
Loading

Step-by-step behaviour:

  • Entries. Each entry is read fully into memory by leer_entrada. Names pass through ZipFile::enclosed_name, which rejects .. and absolute paths (zip-slip), then backslashes are normalised. Unsafe names fail with InvalidInput (motivo = ruta). Entries other than manifest.json and blobs/... are silently ignored.
  • Blob entries. The name must be blobs/<hash>.<ext> with a valid 64-hex <hash> and no extra slash (es_huella); otherwise ruta. The bytes are hashed and must equal the name's hash or the whole import fails (motivo = huella, params.blob). The extension in the file name is not trusted: BlobStore::put_bytes probes the real format from the bytes.
  • Completeness. Every hash in blobs_del_deck(&deck) must either be in the ZIP or already exist locally (blobs.locate), else InvalidInput (motivo = falta_imagen). This check does not cover collection emblems; those are verified later by colecciones::save (blob_missing).
  • Images. put_bytes stores each image (rejecting non-images with Unsupported/Image and sides over 12000 px). The origin is looked up in the deck (deck_origen), so a Scryfall image keeps ScryfallPng/ScryfallLarge (and therefore edge-darkening behaviour); unknown ones become Upload. Existing blobs are deduplicated for free.
  • Collections. A collection whose id already exists locally is not replaced: this computer's version wins. Others are saved through colecciones::save, which also writes the emblem into blob_refs.
  • Deck identity. The imported deck always gets a new uuid and every card a new uuid (so importing the same file twice yields two independent decks). If a deck with exactly the same name exists, it is renamed to "<name> (importado)" (this Spanish suffix is hard-coded in package.rs, which departs from the rule that Rust never writes user-facing prose).
  • Library data. Tags: each name is created or matched case-insensitively to an existing local tag; an existing tag keeps its local colour. Notes are applied if non-empty. The language override is applied only if it is one of the supported codes (etiquetas::IDIOMAS), otherwise ignored. Game lists are saved again with a new id and their copy ids translated to the new card ids (nuevos, the map built while renumbering the cards); ids that are not in the deck are dropped (Game Table).
  • Result. The new Deck is returned and the library reloads. The command opens the file with BufReader.

Safety limits and trust model

Threat Defence
Zip-slip (../x, /etc/passwd) enclosed_name; rejected with ruta (test una_ruta_con_puntos_se_rechaza).
Tampered or swapped image Hash must match the name (test una_imagen_alterada_se_rechaza). Also protects against mislabelled files.
Zip bomb on a single entry MAX_ENTRADA = 512 MiB checked against the declared size and enforced while reading (take(MAX_ENTRADA + 1)); tamano error.
Non-image payload under blobs/ probe in put_bytes rejects it.
Oversized images 12000 px side cap in put_bytes.
Wrong file type format check; test sin_manifiesto_o_de_otro_formato_no_entra.
Package from a newer app Unsupported.
Deck referencing a missing image Rejected up front (falta_imagen) and again by decks::save (blob_missing).

Known gaps to be aware of (read from the code; Inferred where noted as risk):

  • There is no cap on the number of entries or the total uncompressed size, and every entry is buffered in memory, so a crafted package with very many large entries can use a lot of memory. The 512 MiB per-entry cap is the only limit.
  • Import is not transactional across steps. Images and collections are written before the deck is saved, and tags are applied after. If a later step fails, earlier images stay in the blob store as orphans (garbage collection removes them after the 24 h grace period, see Storage-and-Blobs) and a newly saved collection stays.
  • The user sees a generic error text, because the screen translates by error code only (src/lib/errores.ts, for example InvalidInput becomes "Los datos no son válidos."); the specific motivo is not shown. See IPC-and-Error-Handling.

Tests (src-tauri/src/package.rs)

Test Guards
ida_y_vuelta_en_un_equipo_limpio Round trip into an empty library, new ids, same blob set, second import renamed "(importado)".
las_etiquetas_y_las_notas_viajan Tags merge with existing ones by name, notes and language.
las_listas_para_jugar_viajan_con_los_ids_nuevos A game list travels, follows the new card ids and loses an id that was not in the deck.
un_deck_disenado_viaja_con_su_arte_y_su_coleccion Designs, art, emblem and collection survive, and blob_refs protects the art from GC on the new machine.
el_mismo_deck_da_el_mismo_fichero Deterministic output.
una_imagen_alterada_se_rechaza Hash validation.
una_ruta_con_puntos_se_rechaza Zip-slip.
sin_manifiesto_o_de_otro_formato_no_entra Manifest and format checks.

Where to start when extending

  • Adding data to the package: make it an optional field with #[serde(default)] in Manifiesto or Biblioteca, fill it in escribir, apply it at the end of import. Bump VERSION only when an older app would lose or misread data (as designs did); otherwise keep it unchanged so older apps keep importing.
  • Adding a new place that holds image hashes: include it in blobs_del_deck, otherwise the package will be incomplete and import will fail with falta_imagen.
  • Hardening import: add total-size and entry-count caps in the loop of import.

See also

Documentation · Documentación

Language: English · Español

Use and build · Uso y desarrollo

Architecture · Arquitectura

Data · Datos

Getting cards · Conseguir cartas

Layout, images, PDF · Maquetación, imagen y PDF

Card designer · Diseñador de cartas

Game table · Mesa de juego

Frontend · Frontend

Reference · Referencia

Clone this wiki locally