Unpack .skill files & show folder tree with copyable hashes

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
This commit is contained in:
amertensreplit 2026-06-11 01:27:21 +00:00
parent fc4d0d9d28
commit e90a51af50
6 changed files with 267 additions and 53 deletions

View file

@ -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.

View file

@ -4,6 +4,7 @@ import {
parseSingleFile, parseSingleFile,
parseText, parseText,
parseZip, parseZip,
parseUpload,
deriveScanName, deriveScanName,
} from "./skillParser"; } from "./skillParser";
import { hashBytes } from "./skillFingerprint"; 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<string, Uint8Array> = {};
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", () => { describe("deriveScanName", () => {
it("uses the H1 heading from SKILL.md", () => { it("uses the H1 heading from SKILL.md", () => {
const files = [ const files = [

View file

@ -49,6 +49,21 @@ const MAX_ZIP_FILES = 2000;
const MAX_ZIP_TOTAL_BYTES = 60 * 1024 * 1024; const MAX_ZIP_TOTAL_BYTES = 60 * 1024 * 1024;
const MAX_ZIP_FILE_BYTES = 5 * 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 { function extOf(path: string): string {
const base = path.split("/").pop() ?? path; const base = path.split("/").pop() ?? path;
const dot = base.lastIndexOf("."); const dot = base.lastIndexOf(".");
@ -196,6 +211,33 @@ export function parseZip(buffer: Buffer): ParsedFile[] {
return result; 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 { export function parseSingleFile(filename: string, buffer: Buffer): ParsedFile {
const path = filename.replace(/\\/g, "/").split("/").pop() ?? filename; const path = filename.replace(/\\/g, "/").split("/").pop() ?? filename;
const hash = hashBytes(buffer); const hash = hashBytes(buffer);

View file

@ -24,8 +24,7 @@ import {
GetScanLineageResponse, GetScanLineageResponse,
} from "@workspace/api-zod"; } from "@workspace/api-zod";
import { import {
parseZip, parseUpload,
parseSingleFile,
parseText, parseText,
deriveScanName, deriveScanName,
} from "../lib/skillParser"; } from "../lib/skillParser";
@ -301,16 +300,17 @@ function parseScanInput(input: CreateScanInput): ParseResult {
if (input.source === "zip") { if (input.source === "zip") {
if (!input.contentBase64) if (!input.contentBase64)
return { ok: false, status: 400, message: "ZIP-Inhalt fehlt." }; 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") { } else if (input.source === "file") {
if (!input.contentBase64) if (!input.contentBase64)
return { ok: false, status: 400, message: "Dateiinhalt fehlt." }; return { ok: false, status: 400, message: "Dateiinhalt fehlt." };
files = [ files = parseUpload(
parseSingleFile( input.filename ?? "datei",
input.filename ?? "datei", Buffer.from(input.contentBase64, "base64"),
Buffer.from(input.contentBase64, "base64"), );
),
];
} else { } else {
if (!input.text || !input.text.trim()) if (!input.text || !input.text.trim())
return { ok: false, status: 400, message: "Text fehlt." }; return { ok: false, status: 400, message: "Text fehlt." };

View file

@ -369,9 +369,9 @@ export default function ScanForm() {
<Input id="file-single" type="file" onChange={handleFileChange} /> <Input id="file-single" type="file" onChange={handleFileChange} />
</TabsContent> </TabsContent>
<TabsContent value="zip" className="m-0 space-y-2"> <TabsContent value="zip" className="m-0 space-y-2">
<Label htmlFor="file-zip">Skill-Verzeichnis (.zip)</Label> <Label htmlFor="file-zip">Skill-Verzeichnis (.zip oder .skill von Coworker)</Label>
<Input id="file-zip" type="file" accept=".zip" onChange={handleFileChange} /> <Input id="file-zip" type="file" accept=".zip,.skill" onChange={handleFileChange} />
<p className="text-xs text-muted-foreground mt-2">Das Archiv sollte die SKILL.md sowie alle dazugehörigen Skripte enthalten.</p> <p className="text-xs text-muted-foreground mt-2">Das Archiv (.zip oder eine als .skill exportierte Datei) sollte die SKILL.md sowie alle dazugehörigen Skripte enthalten.</p>
</TabsContent> </TabsContent>
<TabsContent value="text" className="m-0 space-y-2"> <TabsContent value="text" className="m-0 space-y-2">
<Label htmlFor="raw-text">Skill Instructions</Label> <Label htmlFor="raw-text">Skill Instructions</Label>

View file

@ -19,9 +19,143 @@ import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { VerdictBadge, SeverityBadge, AxisBadge, CheckpointStatusBadge, CHECKPOINT_STATUS_LABELS, RelationBadge } from "@/components/ui-helpers"; import { VerdictBadge, SeverityBadge, AxisBadge, CheckpointStatusBadge, CHECKPOINT_STATUS_LABELS, RelationBadge } from "@/components/ui-helpers";
import { formatDate } from "@/lib/format"; 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"; 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<FileTreeNode, { type: "dir" }>;
for (let i = 0; i < parts.length - 1; i++) {
const seg = parts[i];
let next = cursor.children.find(
(c): c is Extract<FileTreeNode, { type: "dir" }> => 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<string | null>(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 (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50 text-muted-foreground">
<th className="h-10 px-4 text-left font-medium">Pfad</th>
<th className="h-10 px-4 text-left font-medium">Typ</th>
<th className="h-10 px-4 text-left font-medium">Sprache</th>
<th className="h-10 px-4 text-left font-medium">Hash (SHA-256)</th>
<th className="h-10 px-4 text-right font-medium">Größe</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="p-4 text-center text-muted-foreground">Keine Dateien verfügbar.</td>
</tr>
) : (
rows.map(({ depth, node }, i) =>
node.type === "dir" ? (
<tr key={i} className="border-b last:border-0 bg-muted/20">
<td colSpan={5} className="p-2 px-4">
<span className="inline-flex items-center gap-2 font-medium" style={{ paddingLeft: `${depth * 1.25}rem` }}>
<Folder className="w-4 h-4 text-amber-500 shrink-0" />
{node.name}
</span>
</td>
</tr>
) : (
<tr key={i} className="border-b last:border-0 hover:bg-muted/50 transition-colors">
<td className="p-4 font-mono">
<span className="inline-flex items-center gap-2" style={{ paddingLeft: `${depth * 1.25}rem` }}>
<FileIcon className="w-4 h-4 text-muted-foreground shrink-0" />
{node.name}
</span>
</td>
<td className="p-4">
<Badge variant="outline" className="capitalize">
{node.file.kind === "instruction" ? "Anweisung" : node.file.kind === "script" ? "Skript" : "Ressource"}
</Badge>
</td>
<td className="p-4 text-muted-foreground capitalize">{node.file.language || "-"}</td>
<td className="p-4">
<span className="inline-flex items-center gap-1.5 font-mono text-xs text-muted-foreground">
<span title={node.file.hash}>{node.file.hash ? node.file.hash.slice(0, 12) : "-"}</span>
{node.file.hash && (
<button
type="button"
onClick={() => copyHash(node.file.hash)}
title="Vollständigen SHA-256 kopieren"
aria-label="Vollständigen SHA-256 kopieren"
className="inline-flex items-center justify-center rounded p-1 hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
{copiedHash === node.file.hash ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />}
</button>
)}
{!node.file.hasContent && (
<Badge variant="outline" className="text-[10px]">binär</Badge>
)}
</span>
</td>
<td className="p-4 text-right font-mono">{node.file.size} B</td>
</tr>
),
)
)}
</tbody>
</table>
</div>
);
}
export default function ScanReport() { export default function ScanReport() {
const [, params] = useRoute("/berichte/:id"); const [, params] = useRoute("/berichte/:id");
const id = Number(params?.id); const id = Number(params?.id);
@ -497,48 +631,10 @@ export default function ScanReport() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Geprüfte Dateien</CardTitle> <CardTitle>Geprüfte Dateien</CardTitle>
<CardDescription>Liste aller vom Scanner verarbeiteten Dateien</CardDescription> <CardDescription>Ordnerstruktur aller vom Scanner verarbeiteten Dateien. Klicken Sie auf das Kopier-Symbol für den vollständigen SHA-256.</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="rounded-md border"> <FilesTree files={data.files} />
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50 text-muted-foreground">
<th className="h-10 px-4 text-left font-medium">Pfad</th>
<th className="h-10 px-4 text-left font-medium">Typ</th>
<th className="h-10 px-4 text-left font-medium">Sprache</th>
<th className="h-10 px-4 text-left font-medium">Hash (SHA-256)</th>
<th className="h-10 px-4 text-right font-medium">Größe</th>
</tr>
</thead>
<tbody>
{data.files.length === 0 ? (
<tr>
<td colSpan={5} className="p-4 text-center text-muted-foreground">Keine Dateien verfügbar.</td>
</tr>
) : (
data.files.map((file, i) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/50 transition-colors">
<td className="p-4 font-mono">{file.path}</td>
<td className="p-4">
<Badge variant="outline" className="capitalize">
{file.kind === "instruction" ? "Anweisung" : file.kind === "script" ? "Skript" : "Ressource"}
</Badge>
</td>
<td className="p-4 text-muted-foreground capitalize">{file.language || "-"}</td>
<td className="p-4 font-mono text-xs text-muted-foreground" title={file.hash}>
{file.hash ? file.hash.slice(0, 12) : "-"}
{!file.hasContent && (
<Badge variant="outline" className="ml-2 text-[10px]">binär</Badge>
)}
</td>
<td className="p-4 text-right font-mono">{file.size} B</td>
</tr>
))
)}
</tbody>
</table>
</div>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>