返回 DeepSeek-Reasonix
validation.ts
根目录 / workers / crash-report / src / registry / lib / validation.ts
1 import type { Context } from "hono";
2 import { z } from "zod";
3 import type { AppEnv } from "../env";
4 import { ApiError } from "../http/errors";
5
6 // A capability slug: lowercase, 1–64 chars of [a-z0-9._-], starting and ending
7 // with an alphanumeric. Matches the skill-name rules install_source enforces.
8 const slug = z
9 .string()
10 .trim()
11 .toLowerCase()
12 .regex(/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/, "Use 1–64 chars: letters, digits, '.', '_', '-'.")
13 .max(64);
14
15 const httpUrl = z.string().trim().url().max(500);
16
17 // A publishable install source. The registry stores only a pointer; the real
18 // install runs client-side through install_source, so a source it cannot
19 // classify is dead on arrival. These mirror internal/installsource/names.go
20 // (isURL || git: shorthand || looksLikePackage); a bare local path is refused
21 // because it resolves on the publisher's machine, never the installer's.
22 const pkgSegment = /^[a-zA-Z0-9._-]+$/;
23 const unsafeSourceCharacter = /[\s\u0000-\u001f\u007f-\u009f]/u;
24
25 function hasUnsafeSourceCharacters(source: string): boolean {
26 return unsafeSourceCharacter.test(source);
27 }
28
29 function looksLikePackage(source: string): boolean {
30 if (/[\s\\]/.test(source) || source.startsWith(".") || source.startsWith("/")) return false;
31 if (source.startsWith("@")) {
32 const parts = source.split("/");
33 return parts.length === 2 && pkgSegment.test(parts[0].slice(1)) && pkgSegment.test(parts[1]);
34 }
35 return pkgSegment.test(source);
36 }
37
38 function isHttpUrl(source: string): boolean {
39 if (hasUnsafeSourceCharacters(source)) return false;
40 try {
41 const u = new URL(source);
42 return (u.protocol === "http:" || u.protocol === "https:") && u.hostname !== "";
43 } catch {
44 return false;
45 }
46 }
47
48 function isInstallableSource(source: string): boolean {
49 const raw = source.trim();
50 if (hasUnsafeSourceCharacters(raw)) return false;
51 if (raw.startsWith("git:github.com/") && raw.length > "git:github.com/".length) return true;
52 return isHttpUrl(raw) || looksLikePackage(raw);
53 }
54
55 function isGitHubRepoSource(source: string): boolean {
56 let raw = source.trim();
57 if (hasUnsafeSourceCharacters(raw) || raw.includes("\\")) return false;
58 if (raw.startsWith("git:github.com/")) raw = `https://github.com/${raw.slice("git:github.com/".length)}`;
59 try {
60 const u = new URL(raw);
61 if (
62 (u.protocol !== "http:" && u.protocol !== "https:") ||
63 u.hostname.toLowerCase() !== "github.com" ||
64 u.username !== "" ||
65 u.password !== "" ||
66 u.port !== "" ||
67 u.search !== "" ||
68 u.hash !== ""
69 ) {
70 return false;
71 }
72
73 // URL parsers normalize dot segments before exposing pathname. Inspect
74 // the original encoded path so /tree/main/../outside cannot become a
75 // seemingly safe repository path during validation.
76 const authorityStart = raw.indexOf("://") + 3;
77 const pathStart = raw.indexOf("/", authorityStart);
78 const authorityEnd = pathStart === -1 ? raw.length : pathStart;
79 if (raw.slice(authorityStart, authorityEnd).toLowerCase() !== "github.com") return false;
80 let encodedPath = pathStart === -1 ? "" : raw.slice(pathStart);
81 if (encodedPath.endsWith("/")) encodedPath = encodedPath.slice(0, -1);
82 if (!encodedPath.startsWith("/") || encodedPath.includes("//")) return false;
83
84 const parts = encodedPath
85 .slice(1)
86 .split("/")
87 .map((part) => {
88 try {
89 return decodeURIComponent(part);
90 } catch {
91 return "";
92 }
93 });
94 if (
95 parts.some(
96 (part) =>
97 part === "" ||
98 part === "." ||
99 part === ".." ||
100 part.includes("/") ||
101 part.includes("\\") ||
102 hasUnsafeSourceCharacters(part),
103 )
104 ) {
105 return false;
106 }
107
108 const owner = parts[0] ?? "";
109 const repo = (parts[1] ?? "").replace(/\.git$/i, "");
110 if (!pkgSegment.test(owner) || !pkgSegment.test(repo)) return false;
111 if (parts.length === 2) return true;
112 return parts.length >= 4 && parts[2] === "tree";
113 } catch {
114 return false;
115 }
116 }
117
118 const sourcePointer = z
119 .string()
120 .trim()
121 .min(1)
122 .max(500)
123 .refine(isInstallableSource, {
124 message:
125 "source must be an http(s) URL (SKILL.md, .mcp.json, a repo, or a repo path), a git:github.com/… shorthand, or a package name — not free text or a local path.",
126 });
127
128 // A GitHub source that points at a whole repo — a bare owner/repo root, or a
129 // branch root with no sub-path — rather than one skill. The installsource
130 // planner scans such a source recursively and pulls EVERY SKILL.md it finds, so
131 // a package that claims to be a single skill must not publish one: it would
132 // silently mass-install the repo's entire skill library under this package name.
133 function isWholeGitHubRepoSource(source: string): boolean {
134 let raw = source.trim();
135 if (raw.startsWith("git:github.com/")) raw = `https://github.com/${raw.slice("git:github.com/".length)}`;
136 else if (/^github\.com\//i.test(raw)) raw = `https://${raw}`;
137 let u: URL;
138 try {
139 u = new URL(raw);
140 } catch {
141 return false;
142 }
143 if (u.hostname.toLowerCase() !== "github.com") return false;
144 const parts = u.pathname.split("/").filter(Boolean);
145 // owner/repo → whole repo
146 // owner/repo/tree/<branch> → whole repo at a branch (no sub-path)
147 // owner/repo/tree/<branch>/<path…> → scoped to a path (allowed)
148 // owner/repo/blob/<branch>/<file> → a specific file (allowed)
149 if (parts.length === 2) return true;
150 if (parts.length === 4 && parts[2].toLowerCase() === "tree") return true;
151 return false;
152 }
153
154 export const PublishSchema = z
155 .object({
156 kind: z.enum(["skill", "plugin", "mcp"]),
157 name: slug,
158 summary: z.string().trim().max(200).default(""),
159 description: z.string().trim().max(8000).default(""),
160 source: sourcePointer,
161 installKind: z.enum(["auto", "skill", "plugin", "mcp"]).default("auto"),
162 version: z.string().trim().max(40).default(""),
163 homepage: z.union([httpUrl, z.literal("")]).default(""),
164 repoUrl: z.union([httpUrl, z.literal("")]).default(""),
165 tags: z.array(z.string().trim().min(1).max(30)).max(8).default([]),
166 manifest: z.string().max(16000).default(""),
167 contentHash: z.string().trim().max(128).default(""),
168 riskLevel: z.string().trim().max(20).default(""),
169 })
170 .strict()
171 .superRefine((val, ctx) => {
172 if (val.kind === "skill" && isWholeGitHubRepoSource(val.source)) {
173 ctx.addIssue({
174 code: z.ZodIssueCode.custom,
175 path: ["source"],
176 message:
177 "source points at a whole GitHub repo, which installs every skill in it. Point it at one skill — e.g. https://github.com/<owner>/<repo>/tree/<branch>/skills/<name> or a raw SKILL.md URL.",
178 });
179 }
180 // A skill lives in a SKILL.md file/dir; install_source only reaches the
181 // npx-package branch for kind auto/mcp, so a bare package-name source
182 // (e.g. "123") resolves as an MCP server, never a skill.
183 if (val.kind === "skill" && looksLikePackage(val.source)) {
184 ctx.addIssue({
185 code: z.ZodIssueCode.custom,
186 path: ["source"],
187 message:
188 "a skill source must be a SKILL.md URL or a GitHub repo path, not a bare package name.",
189 });
190 }
191 // Explicit plugin installs clone a GitHub package repository/path; they do
192 // not use the generic URL or npm-package MCP fallbacks.
193 if (val.kind === "plugin" && !isGitHubRepoSource(val.source)) {
194 ctx.addIssue({
195 code: z.ZodIssueCode.custom,
196 path: ["source"],
197 message:
198 "a plugin source must point at a GitHub repository or path containing reasonix-plugin.json, .codex-plugin/plugin.json, .claude-plugin/plugin.json, or a supported .claude-plugin/marketplace.json.",
199 });
200 }
201 if (val.installKind !== "auto" && val.installKind !== val.kind) {
202 ctx.addIssue({
203 code: z.ZodIssueCode.custom,
204 path: ["installKind"],
205 message: "installKind must match kind (or be omitted).",
206 });
207 }
208 })
209 .transform((val) => ({
210 ...val,
211 // The registry's public kind is also the installer's capability boundary.
212 // Never persist `auto`: the client planner probes plugins first for auto
213 // sources, which could otherwise install more than the publisher declared.
214 installKind: val.installKind === "auto" ? val.kind : val.installKind,
215 }));
216
217 export type PublishInput = z.infer<typeof PublishSchema>;
218
219 export const ListQuerySchema = z.object({
220 kind: z.enum(["skill", "plugin", "mcp", "all"]).default("all"),
221 q: z.string().trim().max(100).default(""),
222 sort: z.enum(["new", "trending", "installs"]).default("new"),
223 limit: z.coerce.number().int().min(1).max(100).default(24),
224 offset: z.coerce.number().int().min(0).max(10000).default(0),
225 });
226
227 function firstIssue(error: z.ZodError): string {
228 const issue = error.issues[0];
229 if (!issue) return "Some fields are invalid.";
230 const path = issue.path.join(".");
231 return path ? `${path}: ${issue.message}` : issue.message;
232 }
233
234 export async function parseBody<S extends z.ZodTypeAny>(c: Context<AppEnv>, schema: S): Promise<z.infer<S>> {
235 let raw: unknown;
236 try {
237 raw = await c.req.json();
238 } catch {
239 throw new ApiError(400, "invalid_json", "Request body must be valid JSON.");
240 }
241 const result = schema.safeParse(raw);
242 if (!result.success) throw new ApiError(422, "invalid_input", firstIssue(result.error));
243 return result.data;
244 }
245
246 export function parseQuery<S extends z.ZodTypeAny>(c: Context<AppEnv>, schema: S): z.infer<S> {
247 const params = Object.fromEntries(new URL(c.req.url).searchParams);
248 const result = schema.safeParse(params);
249 if (!result.success) throw new ApiError(422, "invalid_input", firstIssue(result.error));
250 return result.data;
251 }
252
252 lines TYPESCRIPT