Files
ImpTune/.planning/phases/05-package-export/05-RESEARCH.md
T
kawaandClaude Sonnet 4.6 ec6c150585 docs(phase-05): research package export domain
Cover .intunewin assembly (wiring Phase 1 spike), NinjaRMM ZIP builder,
icon upload/validation, and Alpine.js clipboard preview patterns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:47:12 +02:00

25 KiB

Phase 5: Package Export - Research

Researched: 2026-04-10 Domain: .intunewin file assembly, NinjaRMM ZIP packaging, icon upload, FastAPI StreamingResponse / Response binary downloads, HTMX copy-to-clipboard Confidence: HIGH (NinjaRMM ZIP, FastAPI binary responses, icon validation), MEDIUM (.intunewin Intune acceptance — format is implemented but tenant-level acceptance unverified)


<phase_requirements>

Phase Requirements

ID Description Research Support
PKG-01 User can export a complete .intunewin package (script + drivers + detection + metadata) build_intunewin() in imptune/generators/intunewin_builder.py is complete; Phase 5 wires it to a printer config + driver files + rendered scripts
PKG-02 .intunewin is generated natively in Python (no IntuneWinAppUtil.exe dependency) Already implemented in Phase 1 spike using pycryptodome AES-256-CBC; no new libraries needed
PKG-03 User can export a NinjaRMM ZIP package (install script + driver folder) Standard zipfile + io.BytesIO in-memory build, served via FastAPI Response with application/zip
PKG-04 User can upload a custom PNG icon for Intune app display (256x256, max 750KB) UploadFile pattern from drivers.py; Icon ORM model already exists; validation with imghdr (stdlib) or Pillow; icon stored on DATA_DIR volume
PKG-05 User can preview and copy Intune install/uninstall command strings before export Alpine.js navigator.clipboard.writeText() + $el.innerText pattern; rendered server-side in Jinja2 template; no new library needed
</phase_requirements>

Summary

Phase 5 closes out v1 by wiring the already-proven build_intunewin() function and the Phase 4 script renderers into two download endpoints (.intunewin and NinjaRMM ZIP) plus an icon upload endpoint and a command-preview UI.

All cryptographic and ZIP assembly code is complete and tested from Phase 1. The new work is: (1) assembling the right files into a temp directory and calling build_intunewin(), (2) building a NinjaRMM ZIP in memory via io.BytesIO, (3) accepting a PNG upload and storing it to DATA_DIR/icons/, and (4) adding an Alpine.js clipboard copy widget to the printer detail page. No new Python packages are required; zipfile, io, tempfile, and shutil are all stdlib.

The one genuine risk remains .intunewin Intune tenant acceptance — the byte-level format has been reverse-engineered from svrooij.io documentation and validated in unit tests, but a real upload has not been attempted. This is called out as a manual gate before Phase 5 is declared done.

Primary recommendation: Build three new API router files (packages.py for exports, icons.py for upload), add ICONS_DIR to config, update the printer detail page with export buttons and command preview, keep all ZIP assembly in-memory (no temp files on disk).


Standard Stack

Core

Library Version Purpose Why Standard
zipfile stdlib Build NinjaRMM ZIP and inner .intunewin ZIP in memory Already used throughout codebase
io.BytesIO stdlib In-memory byte stream for zip assembly without disk I/O Already used in intunewin_builder.py and drivers.py
tempfile stdlib Temporary directory for .intunewin source staging build_intunewin() requires a source_dir path
shutil stdlib Copy driver ZIP contents into temp staging dir Clean recursive copy
pycryptodome 3.20.* AES-256-CBC encryption for .intunewin (already installed) Phase 1 dependency; no change
FastAPI Response 0.115.* Serve binary file downloads with application/zip Response(content=bytes, media_type=...) is simplest for in-memory content

Supporting

Library Version Purpose When to Use
FastAPI StreamingResponse 0.115.* Alternative for large file streaming Prefer Response for in-memory builds under ~50 MB; use StreamingResponse only if driver packages are so large that holding in RAM is a concern
Pillow (PIL) 10.x PNG validation (dimensions + format) Only if stdlib imghdr is insufficient for size/dimension check; adds a dependency
imghdr stdlib (deprecated 3.13) Basic PNG format detection Acceptable for Python 3.12; but deprecated — prefer Pillow for dimension validation

Alternatives Considered

Instead of Could Use Tradeoff
In-memory io.BytesIO ZIP Write to tmp_path on disk, then stream Disk I/O slower and requires cleanup; in-memory is simpler for packages under ~100 MB
FastAPI Response StreamingResponse with generator StreamingResponse is more complex; Response is sufficient for in-memory byte content
Pillow for icon validation imghdr + manual struct parse Pillow gives dimensions easily; imghdr only identifies format, not size — Pillow preferred

Installation (if Pillow added):

pip install Pillow

Note: Pillow is not in current requirements.txt. Only add it if dimension validation is required by PKG-04. The requirement states "256x256, max 750KB" — dimension check requires Pillow or struct-parsing PNG IHDR chunk manually.


Architecture Patterns

imptune/
├── api/
│   ├── packages.py        # GET /{printer_id}/packages/intunewin, GET /{printer_id}/packages/ninja
│   └── icons.py           # POST /{printer_id}/icon, GET /{printer_id}/icon
├── generators/
│   └── intunewin_builder.py   # Already exists — no changes needed
├── storage/
│   └── icon_store.py      # Analogous to driver_store.py (SHA256 content-addressed)
└── templates/
    └── printer_detail.html   # Add export buttons, command preview, icon upload form

Pattern 1: In-memory NinjaRMM ZIP

What: Build the ZIP entirely in io.BytesIO, return as Response with Content-Disposition: attachment When to use: PKG-03 — NinjaRMM export Example:

# Source: stdlib zipfile + FastAPI Response (project pattern from drivers.py)
import io
import json
import zipfile
from fastapi import APIRouter
from fastapi.responses import Response
from imptune.db.models import Printer
from imptune.generators.script_generator import render_install
import imptune.config as cfg

@router.get("/{printer_id}/packages/ninja")
def download_ninja_package(printer_id: int):
    printer = Printer.get_or_none(Printer.id == printer_id)
    if printer is None:
        return Response("Printer not found", status_code=404, media_type="text/plain")

    driver = printer.driver
    driver_names = json.loads(driver.driver_desc)
    script = render_install(
        printer_name=printer.name,
        ip_address=printer.ip_address,
        port_name=printer.port_name,
        driver_name=driver_names[0],
        inf_filename=driver.inf_filename,
        duplex_mode=printer.duplex_mode,
        color_mode=printer.color_mode,
        paper_size=printer.paper_size,
        collate=printer.collate,
    )

    buf = io.BytesIO()
    driver_zip_path = cfg.DRIVERS_DIR + "/" + driver.sha256  # raw driver ZIP bytes
    driver_bytes = open(driver_zip_path, "rb").read()

    with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATE) as zf:
        safe_name = printer.name.replace(" ", "_")
        zf.writestr(f"{safe_name}/install.ps1", script)
        # Expand driver ZIP into drivers/ subfolder
        with zipfile.ZipFile(io.BytesIO(driver_bytes)) as driver_zf:
            for name in driver_zf.namelist():
                zf.writestr(f"{safe_name}/drivers/{name}", driver_zf.read(name))

    filename = f"{safe_name}_ninja.zip"
    return Response(
        content=buf.getvalue(),
        media_type="application/zip",
        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    )

Pattern 2: .intunewin Package via Temp Directory

What: Stage files to tempfile.mkdtemp(), call build_intunewin(), read output file, clean up When to use: PKG-01/PKG-02 — Intune export Example:

# Source: imptune/generators/intunewin_builder.py (Phase 1 spike)
import io
import json
import os
import shutil
import tempfile
import zipfile
from fastapi.responses import Response
from imptune.generators.intunewin_builder import build_intunewin
from imptune.generators.script_generator import render_install, render_uninstall, render_detect

@router.get("/{printer_id}/packages/intunewin")
def download_intunewin(printer_id: int):
    # ... fetch printer + driver, validate ...

    tmpdir = tempfile.mkdtemp()
    try:
        # 1. Write rendered scripts
        open(os.path.join(tmpdir, "install.ps1"), "w").write(render_install(...))
        open(os.path.join(tmpdir, "uninstall.ps1"), "w").write(render_uninstall(...))
        open(os.path.join(tmpdir, "detect.ps1"), "w").write(render_detect(...))

        # 2. Expand driver ZIP into drivers/ subfolder
        drivers_subdir = os.path.join(tmpdir, "drivers")
        os.makedirs(drivers_subdir)
        driver_zip_path = os.path.join(cfg.DRIVERS_DIR, driver.sha256)
        with zipfile.ZipFile(driver_zip_path) as zf:
            zf.extractall(drivers_subdir)

        # 3. Optionally copy icon
        icon = getattr(printer, "icons", None)
        # ... copy icon if it exists ...

        # 4. Build .intunewin
        output_path = os.path.join(tmpdir, "package.intunewin")
        build_intunewin(tmpdir, "install.ps1", output_path)

        # 5. Read and return
        content = open(output_path, "rb").read()
        safe_name = printer.name.replace(" ", "_")
        return Response(
            content=content,
            media_type="application/octet-stream",
            headers={"Content-Disposition": f'attachment; filename="{safe_name}.intunewin"'},
        )
    finally:
        shutil.rmtree(tmpdir, ignore_errors=True)

Pattern 3: PNG Icon Upload and Validation

What: Accept PNG via UploadFile, validate format + dimensions + size, store SHA256-addressed on disk When to use: PKG-04 — icon upload Example:

# Source: imptune/api/drivers.py upload pattern
from fastapi import UploadFile
from PIL import Image  # if Pillow added

MAX_ICON_BYTES = 750 * 1024  # 750 KB

@router.post("/{printer_id}/icon")
def upload_icon(printer_id: int, file: UploadFile):
    data = file.file.read(MAX_ICON_BYTES + 1)
    if len(data) > MAX_ICON_BYTES:
        return _error_response("Icon exceeds 750 KB limit.")

    # Validate PNG format and dimensions
    try:
        img = Image.open(io.BytesIO(data))
        if img.format != "PNG":
            return _error_response("Icon must be PNG format.")
        if img.size != (256, 256):
            return _error_response(f"Icon must be 256x256 pixels, got {img.size}.")
    except Exception:
        return _error_response("Invalid image file.")

    # Store SHA256-addressed (same as DriverStore pattern)
    sha256 = hashlib.sha256(data).hexdigest()
    icons_dir = Path(cfg.DATA_DIR) / "icons"
    icons_dir.mkdir(parents=True, exist_ok=True)
    dest = icons_dir / sha256
    if not dest.exists():
        dest.write_bytes(data)

    # Upsert Icon ORM record (model already exists in models.py)
    Icon.get_or_none(Icon.printer == printer_id)  # delete old if exists
    Icon.create(printer=printer_id, sha256=sha256,
                original_filename=file.filename, size_bytes=len(data))
    # ... return success partial ...

Pattern 4: Alpine.js Copy-to-Clipboard

What: Display command string in a <code> element; Alpine.js copies it on button click When to use: PKG-05 — Intune command preview Example:

<!-- Source: Alpine.js project pattern (already loaded in base.html) -->
<div x-data="{ copied: false }">
  <code id="install-cmd">powershell.exe -ExecutionPolicy Bypass -File install.ps1</code>
  <button @click="navigator.clipboard.writeText($el.previousElementSibling.innerText);
                   copied = true; setTimeout(() => copied = false, 2000)"
          x-text="copied ? 'Copied!' : 'Copy'">
    Copy
  </button>
</div>

Anti-Patterns to Avoid

  • Writing temp files and not cleaning up: Always use try/finally: shutil.rmtree(tmpdir) — unhandled exceptions skip cleanup
  • Including all files in inner ZIP including uninstall/detect: build_intunewin() packages everything in tmpdir; if uninstall.ps1 should be separate, do NOT put it in tmpdir — it becomes part of the .intunewin payload, which is fine for Intune (it uses SetupFile="install.ps1" as the entry point)
  • Using os.path.join with driver SHA256 directly: SHA256 is 64 hex chars — safe as a filename but must use cfg.DRIVERS_DIR dynamically (monkeypatch pattern from Phase 2)
  • Reading ICONS_DIR as a module-level constant: Same pattern as DRIVERS_DIR — read cfg.DATA_DIR at call time, not import time, so tests can monkeypatch

Don't Hand-Roll

Problem Don't Build Use Instead Why
AES-256-CBC encryption Custom crypto pycryptodome (already installed) Padding oracle attacks, IV reuse bugs
ZIP assembly Custom byte writer zipfile.ZipFile + io.BytesIO ZIP format edge cases (compression flags, CRC, central directory)
PNG format detection + dimensions Manual byte parsing Pillow Image.open() PNG IHDR chunk parsing is 20 lines of struct code that breaks on edge cases
Filename sanitization in ZIPs Custom strip Explicit allowlist + replace() Zip-slip paths (../ prefix) — already handled in drivers.py

Key insight: The hard crypto work (intunewin format) is already done. Phase 5 is assembly and routing only.


Common Pitfalls

Pitfall 1: Temp Directory Leaking on Exception

What goes wrong: tempfile.mkdtemp() creates a directory that persists if an exception is raised before shutil.rmtree() Why it happens: Any error in script rendering, driver extraction, or build_intunewin() bypasses cleanup How to avoid: Always wrap in try/finally block; alternatively use tempfile.TemporaryDirectory() as a context manager (auto-cleanup on __exit__) Warning signs: /tmp fills up with tmp* directories after repeated export calls

Pitfall 2: build_intunewin() Includes Unexpected Files

What goes wrong: build_intunewin() walks the entire source_dir recursively — any extra file added to tmpdir ends up in the package Why it happens: The function design is "pack everything in this directory" How to avoid: Only write install.ps1, detect.ps1, uninstall.ps1, and drivers/ into tmpdir; if icon is embedded in the .intunewin, add it as a known filename (e.g., icon.png) at tmpdir root

Pitfall 3: driver.sha256 File Not Found

What goes wrong: Driver file on disk was deleted but ORM record remains, causing FileNotFoundError during export Why it happens: No referential integrity between ORM and filesystem How to avoid: Check Path(cfg.DRIVERS_DIR, driver.sha256).exists() before proceeding; return HTTP 422 with descriptive message

Pitfall 4: Icon Model Unique Constraint Violation

What goes wrong: Icon model has unique=True on the printer ForeignKey — second upload raises IntegrityError Why it happens: Icon.create() called without checking/deleting existing record How to avoid: Use Icon.get_or_none(Icon.printer == printer_id) then .delete_instance() before Icon.create(), or use INSERT OR REPLACE via Peewee's replace() method

Pitfall 5: driver_desc JSON Parse Failure in Export Endpoint

What goes wrong: driver.driver_desc contains malformed JSON or None Why it happens: Edge case — driver was saved without running INF parsing How to avoid: Reuse _get_printer_and_driver() helper from scripts.py — it already handles this with HTTP 422 responses

Pitfall 6: .intunewin Not Accepted by Intune Tenant

What goes wrong: Real Intune upload rejects the package despite passing all unit tests Why it happens: The byte-level format was reverse-engineered from community documentation (svrooij.io), not from Microsoft's official spec How to avoid: Manual validation gate — upload a test .intunewin to a real Intune tenant before Phase 5 is marked complete (documented as Phase 5 blocker in STATE.md)

Pitfall 7: Alpine.js Clipboard on HTTP (non-HTTPS)

What goes wrong: navigator.clipboard.writeText() throws NotAllowedError in some browsers when page is served over plain HTTP Why it happens: Clipboard API requires secure context (HTTPS or localhost) in modern browsers How to avoid: This tool runs on internal network, typically accessed via IP address. Provide a fallback: show the command text in a <textarea> with select() + document.execCommand('copy') as fallback, or simply display the text prominently and let users copy manually. For localhost access, clipboard API works fine.


Code Examples

Build .intunewin and return as download

# Source: imptune/generators/intunewin_builder.py (Phase 1) + FastAPI Response pattern
import os
import shutil
import tempfile
import zipfile
from fastapi.responses import Response
from imptune.generators.intunewin_builder import build_intunewin

def _build_and_serve_intunewin(printer, driver, driver_name) -> Response:
    tmpdir = tempfile.mkdtemp(prefix="imptune_")
    try:
        # Write scripts
        (Path(tmpdir) / "install.ps1").write_text(render_install(...))
        (Path(tmpdir) / "detect.ps1").write_text(render_detect(printer.name))

        # Expand driver ZIP into drivers/ subfolder
        driver_path = Path(cfg.DRIVERS_DIR) / driver.sha256
        drivers_dir = Path(tmpdir) / "drivers"
        drivers_dir.mkdir()
        with zipfile.ZipFile(driver_path) as zf:
            zf.extractall(str(drivers_dir))

        # Build .intunewin
        output = str(Path(tmpdir) / "out.intunewin")
        build_intunewin(str(tmpdir), "install.ps1", output)

        content = Path(output).read_bytes()
        safe = printer.name.replace(" ", "_")
        return Response(
            content=content,
            media_type="application/octet-stream",
            headers={"Content-Disposition": f'attachment; filename="{safe}.intunewin"'},
        )
    finally:
        shutil.rmtree(tmpdir, ignore_errors=True)

NinjaRMM ZIP in-memory

# Source: stdlib zipfile + io.BytesIO (project pattern from drivers.py)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATE) as zf:
    safe = printer.name.replace(" ", "_")
    zf.writestr(f"{safe}/install.ps1", install_script_text)
    driver_path = Path(cfg.DRIVERS_DIR) / driver.sha256
    with zipfile.ZipFile(driver_path) as drv:
        for member in drv.infolist():
            zf.writestr(f"{safe}/drivers/{member.filename}", drv.read(member.filename))
return Response(
    content=buf.getvalue(),
    media_type="application/zip",
    headers={"Content-Disposition": f'attachment; filename="{safe}_ninja.zip"'},
)

Intune command strings (rendered server-side)

# Source: Intune Win32 app documentation pattern
# Install command: PowerShell with execution policy bypass, relative script path
install_cmd = f"powershell.exe -ExecutionPolicy Bypass -File install.ps1"
# Uninstall command: same pattern
uninstall_cmd = f"powershell.exe -ExecutionPolicy Bypass -File uninstall.ps1"

These strings are static for all printers (the script handles printer-specific config internally). They are rendered into the template as Jinja2 variables.


State of the Art

Old Approach Current Approach When Changed Impact
IntuneWinAppUtil.exe (Windows PE) Python-native AES-256-CBC + zipfile Phase 1 Enables Linux Docker container; eliminates Wine/QEMU dependency
Write to disk, stream file In-memory io.BytesIOResponse.content Project standard Simpler, no cleanup needed for small packages

Deprecated/outdated:

  • imghdr module: deprecated in Python 3.11, removed in 3.13. Project uses Python 3.12 — imghdr still works but Pillow is preferred for dimension validation.
  • @app.on_event("startup"): Not used in this project (lifespan pattern established in Plan 01-01).

Open Questions

  1. Does Pillow need to be added to requirements.txt?

    • What we know: PKG-04 requires 256x256 dimension validation; imghdr cannot check dimensions
    • What's unclear: Acceptable to add Pillow to a minimal-dependencies project?
    • Recommendation: Add Pillow>=10.0 to requirements.txt. Alternative is manual PNG IHDR struct parse (20 lines, error-prone). Pillow is well-maintained, pure wheel available for linux/amd64.
  2. Should uninstall.ps1 be included in the .intunewin package?

    • What we know: Intune Win32 apps have a separate "uninstall command" field pointing to the uninstall script; the script must be inside the .intunewin package
    • What's unclear: Should uninstall.ps1 be in the package root alongside install.ps1?
    • Recommendation: Yes — include install.ps1, uninstall.ps1, detect.ps1, and drivers/ in tmpdir. All three scripts become part of the package payload. Intune's SetupFile points to install.ps1.
  3. Real Intune tenant validation gate

    • What we know: Unit tests pass; format was reverse-engineered from svrooij.io
    • What's unclear: Whether the package is accepted by a real Intune tenant
    • Recommendation: Manual gate — a human must upload a test .intunewin to Intune before marking PKG-01/PKG-02 done. This is already documented in STATE.md as a blocker concern.

Validation Architecture

Test Framework

Property Value
Framework pytest (inferred from existing test suite)
Config file none detected — runs with pytest tests/
Quick run command pytest tests/test_packages.py -x
Full suite command pytest tests/ -x

Phase Requirements → Test Map

Req ID Behavior Test Type Automated Command File Exists?
PKG-01 .intunewin endpoint returns valid ZIP with correct MIME type and Content-Disposition integration pytest tests/test_packages.py::TestIntunewinDownload -x Wave 0
PKG-02 Verify no IntuneWinAppUtil.exe subprocess calls; Python-only assembly unit pytest tests/test_intunewin.py -x exists
PKG-03 NinjaRMM ZIP endpoint returns ZIP containing install.ps1 and drivers/ subfolder integration pytest tests/test_packages.py::TestNinjaDownload -x Wave 0
PKG-04 Icon upload rejects non-PNG, oversized, wrong dimensions; accepted icon stored and retrievable integration pytest tests/test_icon_upload.py -x Wave 0
PKG-05 Printer detail page renders install_cmd and uninstall_cmd strings integration pytest tests/test_packages.py::TestCommandPreview -x Wave 0

Sampling Rate

  • Per task commit: pytest tests/test_packages.py tests/test_icon_upload.py -x
  • Per wave merge: pytest tests/ -x
  • Phase gate: Full suite green + manual .intunewin upload to real Intune tenant

Wave 0 Gaps

  • tests/test_packages.py — covers PKG-01, PKG-03, PKG-05
  • tests/test_icon_upload.py — covers PKG-04
  • requirements.txt — add Pillow>=10.0 if icon dimension validation is implemented

(PKG-02: existing tests/test_intunewin.py already covers the format — no new test file needed)


Sources

Primary (HIGH confidence)

  • imptune/generators/intunewin_builder.py — Complete Phase 1 implementation; AES-256-CBC format, IV=16 bytes, inner ZIP DEFLATE / outer ZIP STORED
  • imptune/db/models.pyIcon model with unique FK to Printer; already in schema
  • imptune/api/drivers.pyUploadFile pattern, error response conventions, size validation
  • imptune/api/scripts.py_get_printer_and_driver() helper pattern for 404/422 guard
  • imptune/config.pyDATA_DIR, DRIVERS_DIR — add ICONS_DIR here
  • FastAPI docs (0.115) — Response(content=bytes, media_type=..., headers=...) for binary downloads

Secondary (MEDIUM confidence)

  • STATE.md accumulated decisions — lifespan pattern, monkeypatch cfg pattern, HTMX error fragment conventions
  • Intune Win32 app command format: powershell.exe -ExecutionPolicy Bypass -File install.ps1 — standard community pattern

Tertiary (LOW confidence)

  • svrooij.io .intunewin reverse-engineering — basis for Phase 1 implementation; not an official Microsoft spec
  • Alpine.js clipboard API behavior on HTTP vs HTTPS — browser-specific; localhost exemption is documented but may vary

Metadata

Confidence breakdown:

  • Standard stack: HIGH — all libraries are already in the project or stdlib
  • Architecture: HIGH — assembly pattern follows established project conventions exactly
  • Pitfalls: HIGH — temp dir cleanup and Icon uniqueness are concrete, verifiable issues
  • .intunewin Intune acceptance: MEDIUM — format is implemented but tenant acceptance is unvalidated

Research date: 2026-04-10 Valid until: 2026-05-10 (stable domain — stdlib + existing project code)