Skip to content

Commit cfd1061

Browse files
authored
Merge pull request #2206 from atomantic/next/issue-2199
feat: verify safetensors integrity after Civitai/HF LoRA download
2 parents f4f5230 + 8eb7b2e commit cfd1061

5 files changed

Lines changed: 140 additions & 17 deletions

File tree

.changelog/NEXT.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
- **Installing a video LoRA from HuggingFace now shows live download progress.** The LoRA Manager's "Install video LoRA from HuggingFace" form (and the curated video quick-install buttons) used to freeze on a static "Downloading…" label with no indication of how far along a multi-GB LoRA was — unlike image-model downloads, which show a percentage. The install now streams byte-level progress over SSE: the button shows `Downloading 42%` and a progress bar with `received / total` bytes fills as it downloads (an indeterminate pulse when the server can't read a Content-Length). Errors — including the "couldn't detect the model family" case that offers the inline "Install as LTX-Video" retry — still surface exactly as before.
6060
- **The LoRA Manager's Installed list is now grouped by Video / Image.** The curated "Video LoRAs" suggestion cards sat directly above a single flat "Installed" grid, so installed *image* LoRAs looked like they belonged to the video section. The Installed section is now visually divided from the suggestions and, in the "All" view, splits into labeled **Video LoRAs** / **Image LoRAs** subsections (mirroring the suggestion panel) so it's obvious which renders each LoRA applies to. The Image/Video filter still collapses it to a single scoped list.
6161
- **[issue-2036] Sketch & Annotation Canvas (phase 2): re-render an image guided by your annotations.** The annotate page (`/media/annotate/:mediaKey`) gained a "Re-render" action: draw over a generated image, then feed your markup back through local img2img so the marks reshape the render. A confirmation dialog names the exact local model it will run (no surprise AI calls) and lets you add an optional prompt and tune how much to change before it starts; the new render is queued and appears in Media History. Requires a local FLUX img2img runner — the action explains when one isn't available. Phase 3 (blank-canvas storyboard) remains open on #2036.
62+
- **[issue-2199] LoRA downloads are now verified for corruption before install.** A truncated or corrupt `.safetensors` download from Civitai or HuggingFace used to install silently and then render garbage ("mosaic") output at generate time. PortOS now checks every freshly downloaded LoRA's structure (safetensors header + size) and, when Civitai provides a SHA-256 for the file, compares it against the bytes on disk. On any mismatch the partial file is deleted and the install fails with a clear error so you re-download instead of training or rendering against corrupt weights.
6263
- **[issue-2196] Video Gen now takes an exact custom Width×Height.** The `/media/video` page only offered a 9-item resolution preset dropdown, so a size the presets didn't cover was only reachable via URL params, remix/continue, or aspect-snap on upload. Two numeric Width and Height inputs now sit beside the preset select — type any dimensions and the render uses them (the server still rounds each down to a multiple of 64 and enforces the per-tier pixel budget). Uploading a source image still auto-snaps the size to the image's aspect ratio by default, until you take the dimensions into your own hands.
6364
- **[issue-2197] First-run model downloads now show GB progress instead of raw text.** The video and image generation handlers now parse byte sizes from `DOWNLOAD:` markers and tqdm progress bars, displaying a formatted "Downloading model · first run · X.X GB / Y.Y GB" message instead of the raw protocol output. Progress frames include `downloadedBytes`/`totalBytes` numeric fields so the UI can build download progress bars. Phase tracking (`isDownloading`, `currentPhase`) ensures the formatting only applies during actual download phases.
6465
- **[issue-2198] A wedged local video render that produces no output is now killed automatically instead of pinning the GPU queue.** The existing completion watchdog only guards a render that finishes and then hangs in teardown — it never armed for a render that stalls *before emitting anything* (a known MLX/Metal failure mode where the job never exits and never prints), which held the serialized GPU lane until you manually canceled. A new idle-stall deadline arms at spawn and resets on every child output line (stdout or stderr); if a render produces no output for a generous window (default 10 minutes, tunable via `VIDEOGEN_IDLE_STALL_MS`) it's SIGKILLed and surfaced as a failed job, so the next queued render can start. The window is deliberately generous so a legitimately slow first-token render (weights load + Metal kernel compile) is never killed, and a manual cancel still works and always wins. (`server/services/videoGen/local.js`)

server/lib/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub
103103
| `ansiStrip.js` | Streaming ANSI / control-byte stripper. |
104104
| `hfToken.js` | HuggingFace token resolution (settings > env > CLI). |
105105
| `hfErrors.js` | Parse huggingface_hub gated-access errors: `extractGatedRepo(text)``owner/name` (or null) for the UI's license deep-link. Shared by the image runner and LoRA trainer. Pure. |
106-
| `hfCache.js` | HuggingFace Hub cache inspection (`inspectModelCache(repoId)``{cached,sizeBytes,snapshotPath}`, `isModelCached`, `getHfCacheRoot`). Drives the inline "Available / Download" badge on the image + video gen forms. Also `verifyModelCache(repoId,{deep})` (structural safetensors-header + optional sha256 integrity check) and `repairModelCache(repoId,{deep})` (delete corrupt weight files so the download path re-fetches them) — power the "Repair model" banner. |
106+
| `hfCache.js` | HuggingFace Hub cache inspection (`inspectModelCache(repoId)``{cached,sizeBytes,snapshotPath}`, `isModelCached`, `getHfCacheRoot`). Drives the inline "Available / Download" badge on the image + video gen forms. Also `verifyModelCache(repoId,{deep})` (structural safetensors-header + optional sha256 integrity check) and `repairModelCache(repoId,{deep})` (delete corrupt weight files so the download path re-fetches them) — power the "Repair model" banner. `verifySafetensorsStructure(path,size)` is the reusable header/size structural check (reads only the header region) — also used by the Civitai/HF LoRA install path to reject truncated downloads. |
107107
| `hfDownload.js` | `downloadHfRepo({repo,onEvent})` returning `{promise,kill}` — spawns `scripts/hf_download_repo.py` in the FLUX.2 venv (fallback: mflux pythonPath) and emits SSE-friendly stage/progress/complete events. Powers the inline "Download" button next to the model picker. |
108108
| `sseHeaders.js` | `SSE_HEADERS` — the canonical SSE response headers (incl. `X-Accel-Buffering:no`) in a dependency-free module so any producer (`sseDownload.js`, `sseUtils.js`) can share them without pulling in a heavier module's transitive imports. |
109109
| `sseDownload.js` | `startHfDownloadStream({req,res,repo,alreadyDownloadedMessage})`, `openSseStream(res)` (`{send,safeEnd}` SSE boilerplate; uses `SSE_HEADERS` from `sseHeaders.js`) — shared SSE driver used by both image and video gen `/models/:id/download` routes. Owns the cross-route in-flight Map so a double-click (or both pages running) can't spawn two python children against the same repo. |

server/lib/hfCache.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ const SHA256_RE = /^[0-9a-f]{64}$/;
167167

168168
// Cheap structural check for a single .safetensors file. Reads only the header
169169
// region (a few KB), never the tensor payload. Returns { ok, reason, ... }.
170-
async function verifySafetensorsStructure(path, size) {
170+
export async function verifySafetensorsStructure(path, size) {
171171
if (size < 8) return { ok: false, reason: 'truncated-header' };
172172
let fd;
173173
try {

server/services/loras.js

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ import { Readable, Transform } from 'stream';
2525
import { pipeline } from 'stream/promises';
2626
import { basename, join } from 'path';
2727
import { ServerError } from '../lib/errorHandler.js';
28-
import { atomicWrite, assertSafeFilename, ensureDir, listDirectoryByExtension, PATHS } from '../lib/fileUtils.js';
28+
import { atomicWrite, assertSafeFilename, ensureDir, listDirectoryByExtension, sha256File, PATHS } from '../lib/fileUtils.js';
29+
import { verifySafetensorsStructure } from '../lib/hfCache.js';
2930
import { isPlainObject } from '../lib/objects.js';
3031
import {
3132
applyDownloadToken,
@@ -367,6 +368,43 @@ const downloadToFile = async (url, destPath, { fetchImpl = fetch, headers = {} ,
367368
});
368369
};
369370

371+
// After a LoRA finishes downloading, verify the on-disk `.safetensors` before
372+
// we commit to it by writing the sidecar. A truncated (short download) or
373+
// right-size-but-wrong-bytes file otherwise installs silently and renders
374+
// garbage ("mosaic" output) at generate time — the leading non-Metal cause of
375+
// corrupt output (issue #2199; mirrors the HF-cache integrity check in
376+
// hfCache.js / #1324). Always runs the cheap structural header/size check
377+
// (reads only the header region, never the multi-GB payload); adds a deep
378+
// sha256 compare only when the source metadata carried a digest (Civitai
379+
// `file.hashes.SHA256`). On any failure the file is deleted so the next install
380+
// re-downloads instead of training/rendering against corrupt weights.
381+
const verifyDownloadedLora = async (destPath, { expectedSha256 = null, source = 'civitai' } = {}) => {
382+
const label = source === 'huggingface' ? 'HuggingFace' : 'Civitai';
383+
const code = source === 'huggingface' ? 'HF_LORA_CORRUPT' : 'CIVITAI_LORA_CORRUPT';
384+
const st = await stat(destPath).catch(() => null);
385+
const structural = await verifySafetensorsStructure(destPath, st?.size ?? 0);
386+
if (!structural.ok) {
387+
await rm(destPath, { force: true }).catch(() => {});
388+
throw new ServerError(
389+
`${label} LoRA download is corrupt (${structural.reason}) — the partial file was deleted. Retry the install.`,
390+
{ status: 502, code },
391+
);
392+
}
393+
// Civitai hashes are uppercase hex; sha256File returns lowercase — compare
394+
// case-insensitively and only when the digest is a well-formed sha256.
395+
const want = typeof expectedSha256 === 'string' ? expectedSha256.trim().toLowerCase() : '';
396+
if (/^[0-9a-f]{64}$/.test(want)) {
397+
const actual = await sha256File(destPath).catch(() => null);
398+
if (actual && actual.toLowerCase() !== want) {
399+
await rm(destPath, { force: true }).catch(() => {});
400+
throw new ServerError(
401+
`${label} LoRA failed SHA-256 verification (expected ${want.slice(0, 12)}…, got ${actual.slice(0, 12)}…) — the file was deleted. Retry the install.`,
402+
{ status: 502, code },
403+
);
404+
}
405+
}
406+
};
407+
370408
// Install a LoRA from a Civitai URL. Returns the new sidecar JSON so the
371409
// client can render it immediately without a second list round-trip.
372410
export const installFromCivitai = async (input, { fetchImpl = fetch } = {}) => {
@@ -435,6 +473,7 @@ export const installFromCivitai = async (input, { fetchImpl = fetch } = {}) => {
435473
headers: { 'User-Agent': 'PortOS/civitai-installer' },
436474
hasApiKey: !!apiKey,
437475
});
476+
await verifyDownloadedLora(destPath, { expectedSha256: file?.hashes?.SHA256 || null, source: 'civitai' });
438477

439478
const sidecar = buildSidecar({ model, version, file, filename });
440479
await atomicWrite(sidecarPath(filename), JSON.stringify(sidecar, null, 2) + '\n');
@@ -506,6 +545,10 @@ export const installFromHuggingface = async (input, { fetchImpl = fetch, onProgr
506545
onProgress,
507546
signal,
508547
});
548+
// HF's model metadata doesn't expose a per-file digest through pickHfLoraFile,
549+
// so the structural header/size check is the integrity guard here (no deep
550+
// sha256 compare available for this path).
551+
await verifyDownloadedLora(destPath, { source: 'huggingface' });
509552

510553
const sidecar = buildHfLoraSidecar({ repo, revision, file, model, family, filename });
511554
await atomicWrite(sidecarPath(filename), JSON.stringify(sidecar, null, 2) + '\n');

0 commit comments

Comments
 (0)