479 lines
25 KiB
Markdown
479 lines
25 KiB
Markdown
# 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):**
|
|
```bash
|
|
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
|
|
|
|
### Recommended Project Structure additions
|
|
```
|
|
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:**
|
|
```python
|
|
# 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:**
|
|
```python
|
|
# 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:**
|
|
```python
|
|
# 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:**
|
|
```html
|
|
<!-- 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
|
|
```python
|
|
# 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
|
|
```python
|
|
# 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)
|
|
```python
|
|
# 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.BytesIO` → `Response.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.py` — `Icon` model with unique FK to `Printer`; already in schema
|
|
- `imptune/api/drivers.py` — `UploadFile` pattern, error response conventions, size validation
|
|
- `imptune/api/scripts.py` — `_get_printer_and_driver()` helper pattern for 404/422 guard
|
|
- `imptune/config.py` — `DATA_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)
|