From e90a51af50ee48f687c928ab662ca723a1925e6d Mon Sep 17 00:00:00 2001 From: amertensreplit <49614208-amertensreplit@users.noreply.replit.com> Date: Thu, 11 Jun 2026 01:27:21 +0000 Subject: [PATCH] Unpack .skill files & show folder tree with copyable hashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Original task (#28): Treat uploaded .skill files like ZIP archives, extract their contents into "Geprüfte Dateien", render the folder structure as a tree, and make the full SHA-256 copyable. Backend (artifacts/api-server): - skillParser.ts: added looksLikeZip() (sniffs PK\x03\x04 signature) and a new parseUpload(filename, buffer) entry point. It extracts when the buffer has a ZIP signature OR a .zip/.skill extension; falls back cleanly to single-file handling when the archive is invalid/empty. Real archives (signature present) still surface limit/corruption errors, so existing ZIP protections (file count, total/per-file size, skipped system dirs) stay in force. - routes/scans.ts: both "zip" and "file" sources now route through parseUpload, so a .skill works whether uploaded via the ZIP area or the single-file area. - skillParser.test.ts: added a parseUpload describe block (extraction, signature detection, .zip via single-file path, invalid-.skill fallback, plain file, limit propagation, empty-archive fallback). 32 parser tests / 66 total pass. Frontend (artifacts/skillguard): - scan-report.tsx: replaced the flat files table with a FilesTree component that derives a folder tree from file paths (folders as nodes, files nested/indented) and adds a copy-to-clipboard button for the full SHA-256 next to the short hash. Type/language/size/binary indicators preserved. - scan-form.tsx: ZIP area now accepts .skill, with updated label/hint. Note: skillguard typecheck initially failed with phantom "property does not exist on ScanDetail" errors due to stale api-client-react dist declarations (project reference). Ran `pnpm run typecheck:libs` to rebuild composite libs; typecheck then passes. Documented in .agents/memory. Verified end-to-end: a .skill upload extracted into 4 files; an invalid .skill fell back to a single file. Test scans cleaned up afterwards. Rebase resolution: - Conflict in scan-report.tsx imports only: main added `ShieldAlert`, this task added `Folder, File as FileIcon, Copy, Check`. Merged both into one import. Replit-Task-Id: 72b2cacc-11eb-412b-82fd-7d5d0cf8f2a4 --- .../memory/api-client-codegen-staleness.md | 13 ++ .../api-server/src/lib/skillParser.test.ts | 63 +++++++ artifacts/api-server/src/lib/skillParser.ts | 42 +++++ artifacts/api-server/src/routes/scans.ts | 18 +- artifacts/skillguard/src/pages/scan-form.tsx | 6 +- .../skillguard/src/pages/scan-report.tsx | 178 ++++++++++++++---- 6 files changed, 267 insertions(+), 53 deletions(-) create mode 100644 .agents/memory/api-client-codegen-staleness.md diff --git a/.agents/memory/api-client-codegen-staleness.md b/.agents/memory/api-client-codegen-staleness.md new file mode 100644 index 0000000..423ed2c --- /dev/null +++ b/.agents/memory/api-client-codegen-staleness.md @@ -0,0 +1,13 @@ +--- +name: api-client-react project-reference staleness +description: Why skillguard typecheck reports phantom "property does not exist on ScanDetail" errors and how to fix. +--- +Frontend artifacts (e.g. `artifacts/skillguard`) consume `@workspace/api-client-react` via a **TypeScript project reference** (composite). tsc resolves the API types from that package's emitted `dist/*.d.ts`, NOT directly from its `src`. + +**Symptom:** `pnpm tsc --noEmit` in an artifact reports errors like `Property 'description'/'relation'/'comparedScan' does not exist on type 'ScanDetail'` even though those fields clearly exist in `lib/api-client-react/src/generated/api.schemas.ts`. The `dist/*.d.ts` is stale relative to the regenerated source. + +**Fix:** run `pnpm run typecheck:libs` from the repo root (it runs `tsc --build`, which rebuilds composite lib declarations). Then the artifact typecheck passes. + +**Why:** after the OpenAPI codegen updates `src`, the composite project's `dist` declarations must be rebuilt in lockstep or every downstream artifact typechecks against the old shape. + +**How to apply:** whenever an artifact typecheck fails on api-client types that you can confirm exist in the client's `src`, rebuild libs first before assuming the code is wrong. diff --git a/artifacts/api-server/src/lib/skillParser.test.ts b/artifacts/api-server/src/lib/skillParser.test.ts index 14cb028..64a9d5c 100644 --- a/artifacts/api-server/src/lib/skillParser.test.ts +++ b/artifacts/api-server/src/lib/skillParser.test.ts @@ -4,6 +4,7 @@ import { parseSingleFile, parseText, parseZip, + parseUpload, deriveScanName, } from "./skillParser"; import { hashBytes } from "./skillFingerprint"; @@ -190,6 +191,68 @@ describe("parseZip", () => { }); }); +describe("parseUpload", () => { + it("extracts a .skill file (a ZIP container) into its individual files", () => { + const zip = zipSync({ + "SKILL.md": strToU8("# My Skill\n"), + "scripts/run.sh": strToU8("#!/bin/sh\necho hi\n"), + }); + const files = parseUpload("my-skill.skill", Buffer.from(zip)); + const paths = files.map((f) => f.path).sort(); + expect(paths).toEqual(["SKILL.md", "scripts/run.sh"]); + }); + + it("detects a ZIP container by signature even with a non-archive name", () => { + const zip = zipSync({ "SKILL.md": strToU8("# Detected by bytes") }); + const files = parseUpload("export.dat", Buffer.from(zip)); + expect(files.map((f) => f.path)).toEqual(["SKILL.md"]); + }); + + it("extracts a .zip uploaded through the single-file path", () => { + const zip = zipSync({ + "SKILL.md": strToU8("# Zip Skill"), + "data.json": strToU8("{}"), + }); + const files = parseUpload("bundle.zip", Buffer.from(zip)); + expect(files.map((f) => f.path).sort()).toEqual(["SKILL.md", "data.json"].sort()); + }); + + it("falls back to single-file handling when a .skill is not a valid archive", () => { + const buf = Buffer.from("# Just markdown, not a real archive", "utf-8"); + const files = parseUpload("plain.skill", buf); + expect(files).toHaveLength(1); + expect(files[0].path).toBe("plain.skill"); + expect(files[0].kind).toBe("resource"); + expect(files[0].isBinary).toBe(false); + expect(files[0].content).toBe("# Just markdown, not a real archive"); + }); + + it("treats a plain non-archive file as a single file", () => { + const buf = Buffer.from("print('hi')", "utf-8"); + const files = parseUpload("main.py", buf); + expect(files).toHaveLength(1); + expect(files[0].path).toBe("main.py"); + expect(files[0].kind).toBe("script"); + expect(files[0].language).toBe("python"); + }); + + it("propagates limit errors for real archives (signature present)", () => { + const entries: Record = {}; + for (let i = 0; i < 2001; i++) { + entries[`f${i}.txt`] = strToU8("x"); + } + const zip = zipSync(entries); + expect(() => parseUpload("big.skill", Buffer.from(zip))).toThrow(); + }); + + it("falls back to single-file when an empty archive yields no files", () => { + const zip = zipSync({ "emptydir/": strToU8("") }); + const files = parseUpload("empty.skill", Buffer.from(zip)); + expect(files).toHaveLength(1); + expect(files[0].path).toBe("empty.skill"); + }); +}); + describe("deriveScanName", () => { it("uses the H1 heading from SKILL.md", () => { const files = [ diff --git a/artifacts/api-server/src/lib/skillParser.ts b/artifacts/api-server/src/lib/skillParser.ts index 548c239..615b5e2 100644 --- a/artifacts/api-server/src/lib/skillParser.ts +++ b/artifacts/api-server/src/lib/skillParser.ts @@ -49,6 +49,21 @@ const MAX_ZIP_FILES = 2000; const MAX_ZIP_TOTAL_BYTES = 60 * 1024 * 1024; const MAX_ZIP_FILE_BYTES = 5 * 1024 * 1024; +/** + * Detects the local-file-header signature ("PK\x03\x04") that every real ZIP + * archive begins with. `.skill` files exported by the Skill tooling are ZIP + * containers, so we sniff the bytes rather than trusting the file extension. + */ +function looksLikeZip(buffer: Buffer): boolean { + return ( + buffer.length >= 4 && + buffer[0] === 0x50 && + buffer[1] === 0x4b && + buffer[2] === 0x03 && + buffer[3] === 0x04 + ); +} + function extOf(path: string): string { const base = path.split("/").pop() ?? path; const dot = base.lastIndexOf("."); @@ -196,6 +211,33 @@ export function parseZip(buffer: Buffer): ParsedFile[] { return result; } +/** + * Entry point for uploaded files (single-file *and* ZIP-area uploads). A + * `.skill` file is really a ZIP container, so we treat any upload that either + * carries the ZIP signature or uses a `.zip`/`.skill` extension as an archive + * and extract it via the streaming ZIP path. If the buffer is not a real + * archive (e.g. someone named a plain text file `.skill`) we fall back cleanly + * to single-file handling instead of failing. Real archives still surface their + * limit/corruption errors so the existing protections stay in force. + */ +export function parseUpload(filename: string, buffer: Buffer): ParsedFile[] { + const isZipSignature = looksLikeZip(buffer); + const hasArchiveExt = /\.(zip|skill)$/i.test(filename); + if (isZipSignature || hasArchiveExt) { + try { + const files = parseZip(buffer); + if (files.length > 0) return files; + // A valid-but-empty archive falls through to single-file handling. + } catch (err) { + // A buffer with a real ZIP signature is genuinely an archive, so limit + // and corruption errors must surface. An extension-only guess that is + // not actually a ZIP falls back to single-file handling. + if (isZipSignature) throw err; + } + } + return [parseSingleFile(filename, buffer)]; +} + export function parseSingleFile(filename: string, buffer: Buffer): ParsedFile { const path = filename.replace(/\\/g, "/").split("/").pop() ?? filename; const hash = hashBytes(buffer); diff --git a/artifacts/api-server/src/routes/scans.ts b/artifacts/api-server/src/routes/scans.ts index 2f9eee3..b5d380e 100644 --- a/artifacts/api-server/src/routes/scans.ts +++ b/artifacts/api-server/src/routes/scans.ts @@ -24,8 +24,7 @@ import { GetScanLineageResponse, } from "@workspace/api-zod"; import { - parseZip, - parseSingleFile, + parseUpload, parseText, deriveScanName, } from "../lib/skillParser"; @@ -301,16 +300,17 @@ function parseScanInput(input: CreateScanInput): ParseResult { if (input.source === "zip") { if (!input.contentBase64) return { ok: false, status: 400, message: "ZIP-Inhalt fehlt." }; - files = parseZip(Buffer.from(input.contentBase64, "base64")); + files = parseUpload( + input.filename ?? "archiv.zip", + Buffer.from(input.contentBase64, "base64"), + ); } else if (input.source === "file") { if (!input.contentBase64) return { ok: false, status: 400, message: "Dateiinhalt fehlt." }; - files = [ - parseSingleFile( - input.filename ?? "datei", - Buffer.from(input.contentBase64, "base64"), - ), - ]; + files = parseUpload( + input.filename ?? "datei", + Buffer.from(input.contentBase64, "base64"), + ); } else { if (!input.text || !input.text.trim()) return { ok: false, status: 400, message: "Text fehlt." }; diff --git a/artifacts/skillguard/src/pages/scan-form.tsx b/artifacts/skillguard/src/pages/scan-form.tsx index e08686b..04b2995 100644 --- a/artifacts/skillguard/src/pages/scan-form.tsx +++ b/artifacts/skillguard/src/pages/scan-form.tsx @@ -369,9 +369,9 @@ export default function ScanForm() { - - -

Das Archiv sollte die SKILL.md sowie alle dazugehörigen Skripte enthalten.

+ + +

Das Archiv (.zip oder eine als .skill exportierte Datei) sollte die SKILL.md sowie alle dazugehörigen Skripte enthalten.

diff --git a/artifacts/skillguard/src/pages/scan-report.tsx b/artifacts/skillguard/src/pages/scan-report.tsx index 25aaee8..fe0c757 100644 --- a/artifacts/skillguard/src/pages/scan-report.tsx +++ b/artifacts/skillguard/src/pages/scan-report.tsx @@ -19,9 +19,143 @@ import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { VerdictBadge, SeverityBadge, AxisBadge, CheckpointStatusBadge, CHECKPOINT_STATUS_LABELS, RelationBadge } from "@/components/ui-helpers"; import { formatDate } from "@/lib/format"; -import { ShieldQuestion, ShieldAlert, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History, GitCommitVertical, Sparkles, Loader2 } from "lucide-react"; +import { ShieldQuestion, ShieldAlert, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History, GitCommitVertical, Sparkles, Loader2, Folder, File as FileIcon, Copy, Check } from "lucide-react"; import type { ScanDetail } from "@workspace/api-client-react"; +type ScanReportFile = ScanDetail["files"][number]; + +type FileTreeNode = + | { type: "file"; name: string; file: ScanReportFile } + | { type: "dir"; name: string; children: FileTreeNode[] }; + +function buildFileTree(files: ScanReportFile[]): FileTreeNode[] { + const root: FileTreeNode = { type: "dir", name: "", children: [] }; + for (const file of files) { + const parts = file.path.split("/").filter(Boolean); + let cursor = root as Extract; + for (let i = 0; i < parts.length - 1; i++) { + const seg = parts[i]; + let next = cursor.children.find( + (c): c is Extract => c.type === "dir" && c.name === seg, + ); + if (!next) { + next = { type: "dir", name: seg, children: [] }; + cursor.children.push(next); + } + cursor = next; + } + cursor.children.push({ type: "file", name: parts[parts.length - 1] ?? file.path, file }); + } + sortFileTree(root.children); + return root.children; +} + +function sortFileTree(nodes: FileTreeNode[]) { + nodes.sort((a, b) => { + if (a.type !== b.type) return a.type === "dir" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + for (const node of nodes) { + if (node.type === "dir") sortFileTree(node.children); + } +} + +type FlatFileRow = { depth: number; node: FileTreeNode }; + +function flattenFileTree(nodes: FileTreeNode[], depth = 0, out: FlatFileRow[] = []): FlatFileRow[] { + for (const node of nodes) { + out.push({ depth, node }); + if (node.type === "dir") flattenFileTree(node.children, depth + 1, out); + } + return out; +} + +function FilesTree({ files }: { files: ScanReportFile[] }) { + const rows = useMemo(() => flattenFileTree(buildFileTree(files)), [files]); + const [copiedHash, setCopiedHash] = useState(null); + + const copyHash = async (hash: string) => { + try { + await navigator.clipboard.writeText(hash); + setCopiedHash(hash); + window.setTimeout(() => setCopiedHash((cur) => (cur === hash ? null : cur)), 1500); + } catch { + // Clipboard unavailable (e.g. non-secure context) — silently ignore. + } + }; + + return ( +
+ + + + + + + + + + + + {rows.length === 0 ? ( + + + + ) : ( + rows.map(({ depth, node }, i) => + node.type === "dir" ? ( + + + + ) : ( + + + + + + + + ), + ) + )} + +
PfadTypSpracheHash (SHA-256)Größe
Keine Dateien verfügbar.
+ + + {node.name} + +
+ + + {node.name} + + + + {node.file.kind === "instruction" ? "Anweisung" : node.file.kind === "script" ? "Skript" : "Ressource"} + + {node.file.language || "-"} + + {node.file.hash ? node.file.hash.slice(0, 12) : "-"} + {node.file.hash && ( + + )} + {!node.file.hasContent && ( + binär + )} + + {node.file.size} B
+
+ ); +} + export default function ScanReport() { const [, params] = useRoute("/berichte/:id"); const id = Number(params?.id); @@ -497,48 +631,10 @@ export default function ScanReport() { Geprüfte Dateien - Liste aller vom Scanner verarbeiteten Dateien + Ordnerstruktur aller vom Scanner verarbeiteten Dateien. Klicken Sie auf das Kopier-Symbol für den vollständigen SHA-256. -
- - - - - - - - - - - - {data.files.length === 0 ? ( - - - - ) : ( - data.files.map((file, i) => ( - - - - - - - - )) - )} - -
PfadTypSpracheHash (SHA-256)Größe
Keine Dateien verfügbar.
{file.path} - - {file.kind === "instruction" ? "Anweisung" : file.kind === "script" ? "Skript" : "Ressource"} - - {file.language || "-"} - {file.hash ? file.hash.slice(0, 12) : "-"} - {!file.hasContent && ( - binär - )} - {file.size} B
-
+