|
1 | | -import { fileURLToPath } from "node:url" |
2 | | - |
3 | | -export type TerminalImageFetchPlan = |
4 | | - | { |
5 | | - readonly _tag: "InvalidTerminalImageFetch" |
6 | | - readonly message: string |
7 | | - } |
8 | | - | { |
9 | | - readonly _tag: "ValidTerminalImageFetch" |
10 | | - readonly containerPath: string |
11 | | - readonly mediaType: string |
12 | | - } |
13 | | - |
14 | | -export const terminalImageFetchMaxBytes = 10 * 1024 * 1024 |
15 | | - |
16 | | -const supportedExtensionMediaTypes = new Map<string, string>([ |
17 | | - ["gif", "image/gif"], |
18 | | - ["jpeg", "image/jpeg"], |
19 | | - ["jpg", "image/jpeg"], |
20 | | - ["png", "image/png"], |
21 | | - ["webp", "image/webp"] |
22 | | -]) |
23 | | - |
24 | | -const controlCharRange = `${String.fromCodePoint(0)}-${String.fromCodePoint(0x1F)}` |
25 | | -const deleteChar = String.fromCodePoint(0x7F) |
26 | | -const invalidCharacterPattern = new RegExp(String.raw`[\s${controlCharRange}${deleteChar}]`, "u") |
27 | | -const traversalPattern = /(?:^|\/)(?:\.|\.\.)(?=\/|$)/u |
28 | | -const urlSchemePattern = /^[A-Za-z][A-Za-z0-9+.-]*:/u |
29 | | -const fileUrlPattern = /^file:\/\//iu |
30 | | -const encodedPathSeparatorPattern = /%(?:2f|5c)/iu |
31 | | -const fileUrlBackslashPattern = /\\/u |
32 | | -const fileUrlTraversalPattern = /(?:^|[\\/])(?:\.|%2e)(?:(?:\.|%2e))?(?=[\\/]|$)/iu |
33 | | - |
34 | | -type TerminalImagePathNormalization = |
35 | | - | { |
36 | | - readonly _tag: "InvalidTerminalImagePath" |
37 | | - readonly message: string |
38 | | - } |
39 | | - | { |
40 | | - readonly _tag: "ValidTerminalImagePath" |
41 | | - readonly path: string |
42 | | - } |
43 | | - |
44 | | -const lowercaseExtension = (path: string): string | null => { |
45 | | - const lastDot = path.lastIndexOf(".") |
46 | | - if (lastDot < 0 || lastDot === path.length - 1) { |
47 | | - return null |
48 | | - } |
49 | | - return path.slice(lastDot + 1).toLowerCase() |
50 | | -} |
51 | | - |
52 | | -const rawFileUrlPathname = (path: string): string => { |
53 | | - const withoutScheme = path.slice("file://".length) |
54 | | - const pathStart = withoutScheme.indexOf("/") |
55 | | - if (pathStart < 0) { |
56 | | - return "" |
57 | | - } |
58 | | - const pathAndSuffix = withoutScheme.slice(pathStart) |
59 | | - const queryStart = pathAndSuffix.indexOf("?") |
60 | | - const hashStart = pathAndSuffix.indexOf("#") |
61 | | - if (queryStart < 0 && hashStart < 0) { |
62 | | - return pathAndSuffix |
63 | | - } |
64 | | - if (queryStart < 0) { |
65 | | - return pathAndSuffix.slice(0, hashStart) |
66 | | - } |
67 | | - if (hashStart < 0) { |
68 | | - return pathAndSuffix.slice(0, queryStart) |
69 | | - } |
70 | | - return pathAndSuffix.slice(0, Math.min(queryStart, hashStart)) |
71 | | -} |
72 | | - |
73 | | -const normalizeTerminalImagePath = (path: string): TerminalImagePathNormalization => { |
74 | | - if (!urlSchemePattern.test(path)) { |
75 | | - return { _tag: "ValidTerminalImagePath", path } |
76 | | - } |
77 | | - if (!fileUrlPattern.test(path)) { |
78 | | - return { _tag: "InvalidTerminalImagePath", message: "Only file:// image URLs are supported." } |
79 | | - } |
80 | | - |
81 | | - const rawPathname = rawFileUrlPathname(path) |
82 | | - if (fileUrlTraversalPattern.test(rawPathname)) { |
83 | | - return { _tag: "InvalidTerminalImagePath", message: "Image path must not contain '.' or '..' segments." } |
84 | | - } |
85 | | - if (encodedPathSeparatorPattern.test(rawPathname) || fileUrlBackslashPattern.test(rawPathname)) { |
86 | | - return { |
87 | | - _tag: "InvalidTerminalImagePath", |
88 | | - message: "Image file URL must not contain encoded or backslash path separators." |
89 | | - } |
90 | | - } |
91 | | - |
92 | | - try { |
93 | | - const url = new URL(path) |
94 | | - if (url.protocol !== "file:" || (url.hostname !== "" && url.hostname !== "localhost")) { |
95 | | - return { _tag: "InvalidTerminalImagePath", message: "Image file URL must point to a local path." } |
96 | | - } |
97 | | - if (url.search.length > 0 || url.hash.length > 0) { |
98 | | - return { _tag: "InvalidTerminalImagePath", message: "Image file URL must not include query or fragment." } |
99 | | - } |
100 | | - return { _tag: "ValidTerminalImagePath", path: fileURLToPath(url, { windows: false }) } |
101 | | - } catch { |
102 | | - return { _tag: "InvalidTerminalImagePath", message: "Image file URL is invalid." } |
103 | | - } |
104 | | -} |
105 | | - |
106 | | -export type TerminalImageFetchOptions = { |
107 | | - readonly baseDir?: string |
108 | | -} |
109 | | - |
110 | | -const isAbsolutePosixPath = (value: string): boolean => value.startsWith("/") |
111 | | - |
112 | | -const joinBaseDirAndRelativePath = (baseDir: string, relativePath: string): string => { |
113 | | - const trimmedBase = baseDir.replace(/\/+$/u, "") |
114 | | - return `${trimmedBase}/${relativePath}` |
115 | | -} |
116 | | - |
117 | | -export const planTerminalImageFetch = ( |
118 | | - path: string, |
119 | | - options: TerminalImageFetchOptions = {} |
120 | | -): TerminalImageFetchPlan => { |
121 | | - if (typeof path !== "string" || path.length === 0) { |
122 | | - return { _tag: "InvalidTerminalImageFetch", message: "Image path is required." } |
123 | | - } |
124 | | - const normalized = normalizeTerminalImagePath(path) |
125 | | - if (normalized._tag === "InvalidTerminalImagePath") { |
126 | | - return { _tag: "InvalidTerminalImageFetch", message: normalized.message } |
127 | | - } |
128 | | - const normalizedPath = normalized.path |
129 | | - let containerPath = normalizedPath |
130 | | - if (!isAbsolutePosixPath(containerPath)) { |
131 | | - const baseDir = options.baseDir |
132 | | - if (baseDir === undefined || !isAbsolutePosixPath(baseDir)) { |
133 | | - return { _tag: "InvalidTerminalImageFetch", message: "Image path must be absolute." } |
134 | | - } |
135 | | - if (invalidCharacterPattern.test(baseDir) || traversalPattern.test(baseDir)) { |
136 | | - return { _tag: "InvalidTerminalImageFetch", message: "Image base directory is invalid." } |
137 | | - } |
138 | | - containerPath = joinBaseDirAndRelativePath(baseDir, containerPath) |
139 | | - } |
140 | | - if (invalidCharacterPattern.test(containerPath)) { |
141 | | - return { _tag: "InvalidTerminalImageFetch", message: "Image path contains invalid characters." } |
142 | | - } |
143 | | - if (traversalPattern.test(containerPath)) { |
144 | | - return { _tag: "InvalidTerminalImageFetch", message: "Image path must not contain '.' or '..' segments." } |
145 | | - } |
146 | | - const extension = lowercaseExtension(containerPath) |
147 | | - if (extension === null) { |
148 | | - return { _tag: "InvalidTerminalImageFetch", message: "Image path must include a file extension." } |
149 | | - } |
150 | | - const mediaType = supportedExtensionMediaTypes.get(extension) |
151 | | - if (mediaType === undefined) { |
152 | | - return { _tag: "InvalidTerminalImageFetch", message: `Unsupported image extension: .${extension}` } |
153 | | - } |
154 | | - return { _tag: "ValidTerminalImageFetch", containerPath, mediaType } |
155 | | -} |
| 1 | +export { |
| 2 | + planTerminalImageFetch, |
| 3 | + terminalImageFetchMaxBytes, |
| 4 | + type TerminalImageFetchOptions, |
| 5 | + type TerminalImageFetchPlan |
| 6 | +} from "@prover-coder-ai/docker-git-terminal/server" |
0 commit comments