1+ import { fileURLToPath } from "node:url"
2+
13export type TerminalImageFetchPlan =
24 | {
35 readonly _tag : "InvalidTerminalImageFetch"
@@ -23,6 +25,21 @@ const controlCharRange = `${String.fromCodePoint(0)}-${String.fromCodePoint(0x1F
2325const deleteChar = String . fromCodePoint ( 0x7F )
2426const invalidCharacterPattern = new RegExp ( String . raw `[\s${ controlCharRange } ${ deleteChar } ]` , "u" )
2527const traversalPattern = / (?: ^ | \/ ) (?: \. | \. \. ) (? = \/ | $ ) / u
28+ const urlSchemePattern = / ^ [ A - Z a - z ] [ A - Z a - z 0 - 9 + . - ] * : / u
29+ const fileUrlPattern = / ^ f i l e : \/ \/ / iu
30+ const encodedPathSeparatorPattern = / % (?: 2 f | 5 c ) / iu
31+ const fileUrlBackslashPattern = / \\ / u
32+ const fileUrlTraversalPattern = / (?: ^ | [ \\ / ] ) (?: \. | % 2 e ) (?: (?: \. | % 2 e ) ) ? (? = [ \\ / ] | $ ) / 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+ }
2643
2744const lowercaseExtension = ( path : string ) : string | null => {
2845 const lastDot = path . lastIndexOf ( "." )
@@ -32,26 +49,85 @@ const lowercaseExtension = (path: string): string | null => {
3249 return path . slice ( lastDot + 1 ) . toLowerCase ( )
3350}
3451
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+
35106export const planTerminalImageFetch = ( path : string ) : TerminalImageFetchPlan => {
36107 if ( typeof path !== "string" || path . length === 0 ) {
37108 return { _tag : "InvalidTerminalImageFetch" , message : "Image path is required." }
38109 }
39- if ( ! path . startsWith ( "/" ) ) {
110+ const normalized = normalizeTerminalImagePath ( path )
111+ if ( normalized . _tag === "InvalidTerminalImagePath" ) {
112+ return { _tag : "InvalidTerminalImageFetch" , message : normalized . message }
113+ }
114+ const containerPath = normalized . path
115+ if ( ! containerPath . startsWith ( "/" ) ) {
40116 return { _tag : "InvalidTerminalImageFetch" , message : "Image path must be absolute." }
41117 }
42- if ( invalidCharacterPattern . test ( path ) ) {
118+ if ( invalidCharacterPattern . test ( containerPath ) ) {
43119 return { _tag : "InvalidTerminalImageFetch" , message : "Image path contains invalid characters." }
44120 }
45- if ( traversalPattern . test ( path ) ) {
121+ if ( traversalPattern . test ( containerPath ) ) {
46122 return { _tag : "InvalidTerminalImageFetch" , message : "Image path must not contain '.' or '..' segments." }
47123 }
48- const extension = lowercaseExtension ( path )
124+ const extension = lowercaseExtension ( containerPath )
49125 if ( extension === null ) {
50126 return { _tag : "InvalidTerminalImageFetch" , message : "Image path must include a file extension." }
51127 }
52128 const mediaType = supportedExtensionMediaTypes . get ( extension )
53129 if ( mediaType === undefined ) {
54130 return { _tag : "InvalidTerminalImageFetch" , message : `Unsupported image extension: .${ extension } ` }
55131 }
56- return { _tag : "ValidTerminalImageFetch" , containerPath : path , mediaType }
132+ return { _tag : "ValidTerminalImageFetch" , containerPath, mediaType }
57133}
0 commit comments