# ZIP handoff lab

Companion to **Google Playground Export: Check the Game ZIP Before Handoff**.

This is an original, synthetic exercise. The included **Ten Tiny Targets** button-counter game, SVG target, ZIPs, and code were authored for this lab. None was generated by or exported from Google Playground. No Playground session, exported product, browser game, mobile device, or network behavior was tested to produce these results.

Google's [official help center](https://playground.google/helpcenter) documents Download Zip as a download containing HTML, JavaScript, CSS, and generated assets. That product description motivates the handoff checklist; it is not evidence that a particular export is complete or works offline.

## Run it

Use Python 3.10 or later with its standard `zlib` module. No third-party packages, account, API key, browser, or network access are needed.

From this folder:

```sh
python3 build_fixtures.py
python3 -m unittest -v test_audit_zip.py
python3 audit_zip.py fixtures/01-complete.zip fixtures/02-missing-texture.zip fixtures/03-external-resource.zip --output report.json
```

The last command intentionally exits **2**, because the batch includes a broken fixture. It still writes the complete JSON report. This is the expected result, not a failed installation.

For your own archive:

```sh
python3 audit_zip.py /path/to/game.zip --output my-report.json
```

Omit `--output` to print JSON. Exit codes: `0` means all static checks passed within scope; `1` means warnings need review; `2` means at least one error, or invalid CLI usage. The scanner never extracts ZIP contents, executes game code, launches a browser, or fetches a URL. It does write the requested report file. An output path resolving to an input archive is rejected.

To rebuild the downloadable lab bundle after changing public files:

```sh
python3 build_bundle.py
```

## The three measured fixture outcomes

`report.json` is actual output from the included checker against the included synthetic ZIPs, not a hand-written example report or a Playground performance measurement.

| Original fixture | Static outcome | Finding |
| --- | --- | --- |
| `fixtures/01-complete.zip` | Static checks passed | Its one HTML-local resource resolves to the included SVG |
| `fixtures/02-missing-texture.zip` | Static checks failed | `assets/target.svg` is deliberately absent |
| `fixtures/03-external-resource.zip` | Review warnings | An added `https://example.invalid/bonus.svg` reference is external |

The `.invalid` domain is deliberately non-production. The checker does not contact it. The third fixture retains the same valid local SVG resource.

The fixture builder uses fixed ZIP timestamps, sorted entry names, and stored compression so repeated builds are byte-identical across zlib versions. The tests also exercise deflated archives and data-descriptor ZIPs.

## What the checker does

1. Checks archive size before loading ZIP metadata.
2. Rejects absolute/drive-qualified paths, parent traversal, control characters, backslashes, colons, noncanonical paths, duplicate entries, case/Unicode-normalized name collisions, and file-versus-directory conflicts. Original names are checked, including embedded NULs that some ZIP APIs truncate.
3. Rejects symbolic links, device/special entries, ambiguous file types, directory payloads, encrypted entries, and compression methods other than stored or DEFLATE.
4. Checks declared size, count, and compression-ratio budgets before decompressing. Any structural error skips content scanning.
5. Reads files in bounded chunks without extraction. Raw DEFLATE decoding checks end-of-stream, trailing compressed bytes, output size, and CRC, so a false small `file_size` cannot silently hide larger decompressed output. Runtime budgets still apply. CRC detects accidental corruption; it is not an authenticity or malware check.
6. Requires an exact root `index.html` file and parses UTF-8 `.html`/`.htm` files with Python's HTML parser.
7. Records statically declared HTML `src` and `href` URLs. Local targets are compared against archive files with exact, case-sensitive matching. Missing files are errors; external HTTP(S) and protocol-relative URLs are warnings.

### URL rules and deliberate conservative choices

- Query strings and fragments are removed **before** percent-decoding the path exactly once. Thus `art/my%20target%23%3F.svg?v=1#shape` checks for the literal file `art/my target#?.svg`. Fragment IDs themselves are not inspected.
- Empty URLs, fragment-only URLs, and query-only URLs refer to the current HTML file. Relative paths resolve from that HTML file's directory; `../` is allowed only when it remains inside the archive.
- Root-relative paths such as `/assets/x.svg` resolve from the archive root **with a warning**: this assumes web-root hosting. `file://` and subpath hosting can behave differently.
- Malformed percent escapes, invalid UTF-8 escapes, encoded path separators, backslashes, path controls, paths escaping the archive, and directory URLs are rejected rather than guessed. Server rewrites, directory indexes, and filesystem aliases are not simulated.
- A `base href` changes resolution for the entire HTML document. The checker reports an error and skips that document's resource resolution. A `srcset` attribute gets a warning because its candidates are not inspected.
- `data:` URLs are recorded as embedded, without validating their content. Other non-file schemes such as `blob:`, `javascript:`, `file:`, and `mailto:` get an out-of-scope warning.
- Duplicate `src`/`href` attributes get a warning. Non-UTF-8 HTML or declared non-UTF-8 charsets are rejected. Python's parser is not a browser's complete HTML parser or an HTML-conformance validator.

### Default budgets

| Limit | Value |
| --- | ---: |
| ZIP file bytes, before parsing | 20 MiB |
| Entries, after central-directory parsing | 500 |
| Uncompressed bytes per file | 10 MiB |
| Uncompressed bytes across files | 50 MiB |
| Bytes per HTML document | 2 MiB |
| Declared and actual compression ratio | 100:1 |
| HTML reference/finding count across the archive | 10,000 |
| Input/output decompression chunk | 64 KiB |

Budgets are intentionally conservative and may reject legitimate large or highly compressible projects. Python callers can provide a `Limits` object; changing the defaults changes the accepted workload. A runtime rejection can occur after one output chunk crosses a threshold.

These are practical guardrails for an educational static checker, **not a strict RAM/CPU sandbox or a comprehensive hostile-archive validator**. In particular, Python loads the central directory before the 500-entry limit is checked; the 20 MiB input cap does not guarantee a small metadata allocation. Use an appropriately isolated environment for untrusted archives. The tool does not authorize extracting or executing them.

## What remains untested

Every report includes an explicit `scope.not_checked` list. The checker does not inspect CSS `url()` or `@import`, inline styles, JavaScript imports or fetches, generated URLs, `srcset` candidates, manifests, inline SVG dependencies, or other resource-bearing attributes. It does not run gameplay, check fragment targets, test external availability, confirm licensing, scan for malware, or prove security or offline readiness. “Static checks passed” applies only to the implemented checks. It is not a handoff acceptance certificate.

Use `handoff-checklist.csv` as a blank manual worksheet for an independently chosen game and target environment. Every row starts `not-run`; observed behavior and result cells are blank. The worksheet covers start, core action, win/lose if applicable, restart, repeated play, keyboard, mobile touch, and network/late-asset behavior. **No row records a performed browser or device test.** The included counter fixture has no lose state; record not-applicable only after reviewing the game you actually test.

## Files

- `audit_zip.py`: standalone offline CLI and Python API
- `test_audit_zip.py`: regression tests, including corrupt and adversarial ZIP cases
- `build_fixtures.py`: reproducible builder for the three original fixture ZIPs
- `source/index.html`, `source/assets/target.svg`: original game source and original vector target
- `fixtures/*.zip`: three original synthetic archives
- `report.json`: generated checker output for those archives
- `handoff-checklist.csv`: reusable unrun browser/device worksheet
- `build_bundle.py`: deterministic public lab packager
- `playground-export-zip-lab.zip`: code, fixture, report, and worksheet download

The article's separate conceptual hero image is not part of this code bundle.

## References

- [Google Playground help center](https://playground.google/helpcenter): product Download Zip description
- [Python `zipfile` documentation](https://docs.python.org/3/library/zipfile.html): ZIP support and decompression pitfalls
- [Python `html.parser` documentation](https://docs.python.org/3/library/html.parser.html): static HTML parsing API
- [HTML standard: the base element](https://html.spec.whatwg.org/multipage/semantics.html#the-base-element): why base URLs affect reference resolution
