char/avril
(wip) why'd you have to go and make things so complicaaaaated?
git clone https://git.t4t.associates/char/avril
38d5bbf
main
1// File URL conversions for `avril:path`, bound explicitly to POSIX or 2// Windows path semantics. The WHATWG URL class and its property getters are 3// captured from `ext:url` at module scope, never read back through the 4// guest-mutable `globalThis.URL`; the standard percent-codec is captured the 5// same way, so poisoning the globals afterwards changes nothing. 6 7import { 8ArrayPrototypeJoin , 9ArrayPrototypePush , 10StringPrototypeCharCodeAt , 11StringPrototypeIncludes , 12StringPrototypeIndexOf , 13StringPrototypeIsWellFormed , 14StringPrototypeSlice , 15StringPrototypeToLowerCase , 16TypeError , 17getter , 18} from "ext:core/primordials" ; 19import { URL as AvrilURL } from "ext:url/main.js" ; 20import { splitOnChar , validateString , win32Base } from "./lexical.js" ; 21 22const { decodeURIComponent, encodeURIComponent} = globalThis ; 23 24const getHref = getter ( AvrilURL . prototype , "href" ); 25const getProtocol = getter ( AvrilURL . prototype , "protocol" ); 26const getHostname = getter ( AvrilURL . prototype , "hostname" ); 27const getPathname = getter ( AvrilURL . prototype , "pathname" ); 28const getSearch = getter ( AvrilURL . prototype , "search" ); 29const getHash = getter ( AvrilURL . prototype , "hash" ); 30 31const CHAR_FORWARD_SLASH = 47 ; // / 32const CHAR_BACKWARD_SLASH = 92 ; // \ 33 34function hexValueAt ( input :string , index :number ) :number { 35if ( index < 0 || index >= input . length ) return - 1 ; 36const code = StringPrototypeCharCodeAt ( input , index ); 37if ( code >= 48 && code <= 57 ) return code - 48 ; 38if ( code >= 65 && code <= 70 ) return code - 55 ; 39if ( code >= 97 && code <= 102 ) return code - 87 ; 40return - 1 ; 41} 42 43function rejectNul ( value :string ) :void { 44if ( StringPrototypeIncludes ( value , "\0" )) { 45throw new TypeError ( "File URL conversion does not accept NUL bytes in paths" ); 46} 47} 48 49// The standard codec rejects malformed escapes, truncated sequences, 50// overlong forms, surrogate-range encodings, and out-of-range code points; 51// NUL still decodes quietly, so it is checked explicitly afterwards along 52// with well-formedness as defense in depth. 53function percentDecode ( input :string ) :string { 54if ( StringPrototypeIndexOf ( input , "%" ) === - 1 ) { 55rejectNul ( input ); 56return input ; 57} 58let decoded ; 59try { 60decoded = decodeURIComponent ( input ); 61} catch { 62throw new TypeError ( "File URL path has a malformed percent-encoding" ); 63} 64rejectNul ( decoded ); 65if ( ! StringPrototypeIsWellFormed ( decoded )) { 66throw new TypeError ( "File URL path decodes to lone surrogates" ); 67} 68return decoded ; 69} 70 71// Rejects encoded separators in the still-encoded pathname: separating 72// slashes must arrive literally so the URL parser, not an escape, chose them. 73function assertNoEncodedSeparator ( raw :string , windows :boolean ) :void { 74for ( let i = 0 ; i < raw . length ; i ++ ) { 75if ( StringPrototypeCharCodeAt ( raw , i ) !== 37 ) continue ; // % 76const high = hexValueAt ( raw , i + 1 ); 77const low = hexValueAt ( raw , i + 2 ); 78if ( high < 0 || low < 0 ) continue ; 79const byte = high * 16 + low ; 80if ( byte === CHAR_FORWARD_SLASH || ( windows && byte === CHAR_BACKWARD_SLASH )) { 81throw new TypeError ( "File URL path must not include encoded path separators" ); 82} 83} 84} 85 86function parseFileUrl ( value :unknown ) :AvrilURL { 87if ( typeof value === "string" ) { 88// WHATWG parsing normalizes lone surrogates to U+FFFD and silently drops 89// ASCII tab/newline characters, so reject those inputs up front. A URL 90// instance is already parsed and cannot be un-normalized; that residual 91// replacement is documented rather than re-detected here. 92if ( ! StringPrototypeIsWellFormed ( value )) { 93throw new TypeError ( "File URL conversion does not accept lone surrogates in URLs" ); 94} 95rejectNul ( value ); 96for ( let i = 0 ; i < value . length ; i ++ ) { 97const code = StringPrototypeCharCodeAt ( value , i ); 98if ( code === 9 || code === 10 || code === 13 ) { 99throw new TypeError ( "File URL strings must not contain tab or newline characters" ); 100} 101} 102try { 103return new AvrilURL ( value ); 104} catch { 105throw new TypeError ( "Expected an absolute file: URL string or URL instance" ); 106} 107} 108try { 109getHref ( value ); 110} catch { 111throw new TypeError ( "Expected an absolute file: URL string or URL instance" ); 112} 113return value as AvrilURL ; 114} 115 116function checkFileUrl ( url :AvrilURL ) :void { 117if ( getProtocol ( url ) !== "file:" ) { 118throw new TypeError ( "File URL conversion requires a file: URL" ); 119} 120if ( getSearch ( url ) !== "" || getHash ( url ) !== "" ) { 121throw new TypeError ( "File URL conversion does not accept URLs with a query or fragment" ); 122} 123} 124 125function isDriveLetter ( code :number ) :boolean { 126return ( code >= 65 && code <= 90 ) || ( code >= 97 && code <= 122 ); 127} 128 129function rejectExtendedNamespace ( path :string ) :void { 130const first = StringPrototypeCharCodeAt ( path , 0 ); 131const second = StringPrototypeCharCodeAt ( path , 1 ); 132const third = StringPrototypeCharCodeAt ( path , 2 ); 133const fourth = StringPrototypeCharCodeAt ( path , 3 ); 134const separator = ( code :number ) => 135code === CHAR_FORWARD_SLASH || code === CHAR_BACKWARD_SLASH ; 136if ( 137separator ( first ) && separator ( second ) && 138( third === 63 || third === 46 ) && separator ( fourth ) 139) { 140throw new TypeError ( 141"File URL conversion does not accept extended-namespace paths (\\\\?\\ or \\\\.\\)" , 142); 143} 144} 145 146function posixFromFileUrl ( value :unknown ) :string { 147const url = parseFileUrl ( value ); 148checkFileUrl ( url ); 149const host = getHostname ( url ); 150if ( host !== "" && host !== "localhost" ) { 151throw new TypeError ( 152`File URL host " ${ host } " is not local; POSIX paths accept only empty or localhost hosts` , 153); 154} 155const raw = getPathname ( url ); 156assertNoEncodedSeparator ( raw , false ); 157return percentDecode ( raw ); 158} 159 160function win32FromFileUrl ( value :unknown ) :string { 161const url = parseFileUrl ( value ); 162checkFileUrl ( url ); 163const host = getHostname ( url ); 164const raw = getPathname ( url ); 165assertNoEncodedSeparator ( raw , true ); 166// WHATWG file URL paths always use forward slashes. 167const decoded = ArrayPrototypeJoin ( splitOnChar ( percentDecode ( raw ), CHAR_FORWARD_SLASH ), "\\" ); 168if ( host !== "" && StringPrototypeToLowerCase ( host ) !== "localhost" ) { 169if ( decoded . length < 2 ) { 170throw new TypeError ( "File URL names an incomplete UNC path" ); 171} 172const segEnd = StringPrototypeIndexOf ( decoded , "\\" , 1 ); 173const first = segEnd === - 1 174 ?StringPrototypeSlice ( decoded , 1 ) 175 :StringPrototypeSlice ( decoded , 1 , segEnd ); 176if ( first === "" || first === "." || first === ".." ) { 177throw new TypeError ( "File URL names an incomplete UNC path" ); 178} 179const unc = `\\\\ ${ host } ${ decoded } ` ; 180rejectExtendedNamespace ( unc ); 181return unc ; 182} 183// Only fully qualified drive paths convert: drive-relative (`C:`, `C:foo`) 184// names no complete absolute location without consulting the process 185// working directory, which URL conversion deliberately never does. 186if ( 187! isDriveLetter ( StringPrototypeCharCodeAt ( decoded , 1 ) | 32 ) || 188StringPrototypeCharCodeAt ( decoded , 2 ) !== 58 || // : 189decoded . length < 4 || 190StringPrototypeCharCodeAt ( decoded , 3 ) !== CHAR_BACKWARD_SLASH 191) { 192throw new TypeError ( "File URL path must be absolute" ); 193} 194const drive = StringPrototypeSlice ( decoded , 1 ); 195rejectExtendedNamespace ( drive ); 196return drive ; 197} 198 199// ASCII characters kept literally in generated file URLs, matching Node's 200// pathToFileURL table: alphanumerics, sub-delims, `@:`, and `;`, `=`, `,`, 201// `-`, `.`, `_`. Everything else (including `%`, space, `"#<>?`, backtick, 202// braces, `~`, `|^[]`, backslash, controls, DEL, and non-ASCII as UTF-8) is 203// percent-encoded by the standard codec with uppercase hex. 204function isPathCharKept ( code :number ) :boolean { 205return ( code >= 48 && code <= 57 ) || ( code >= 65 && code <= 90 ) || 206( code >= 97 && code <= 122 ) || code === 33 || code === 36 || code === 38 || 207code === 39 || code === 40 || code === 41 || code === 42 || code === 43 || 208code === 44 || code === 45 || code === 46 || code === 58 || code === 59 || 209code === 61 || code === 64 || code === 95 ; 210} 211 212// `path` uses forward slashes throughout; callers normalize Windows 213// separators first so backslashes never leak in literally. 214function encodeAbsolutePath ( path :string ) :string { 215const pieces :string [] = []; 216let start = 0 ; 217for ( let i = 0 ; i < path . length ;) { 218const code = StringPrototypeCharCodeAt ( path , i ); 219if ( code === CHAR_FORWARD_SLASH || ( code < 128 && isPathCharKept ( code ))) { 220i ++ ; 221continue ; 222} 223ArrayPrototypePush ( pieces , StringPrototypeSlice ( path , start , i )); 224// The standard codec leaves `~` literally, but Node's file URLs encode 225// it, so it is escaped here to match. 226if ( code === 126 ) { 227ArrayPrototypePush ( pieces , "%7E" ); 228i ++ ; 229} else if ( code >= 55296 && code <= 56319 ) { 230ArrayPrototypePush ( pieces , encodeURIComponent ( StringPrototypeSlice ( path , i , i + 2 ))); 231i += 2 ; 232} else { 233ArrayPrototypePush ( pieces , encodeURIComponent ( StringPrototypeSlice ( path , i , i + 1 ))); 234i ++ ; 235} 236start = i ; 237} 238ArrayPrototypePush ( pieces , StringPrototypeSlice ( path , start , path . length )); 239return ArrayPrototypeJoin ( pieces , "" ); 240} 241 242// UNC servers are validated strictly instead of percent-encoded: the URL 243// parser silently strips some invalid characters (notably tab and newline), 244// which would otherwise point the resulting URL elsewhere. Bracketed IPv6 245// literals are checked structurally; other non-ASCII names pass through for 246// the parser's IDNA handling and normalize to punycode. 247function checkUncHost ( host :string ) :string { 248if ( StringPrototypeToLowerCase ( host ) === "localhost" ) { 249throw new TypeError ( 250"toFileUrl does not represent the local machine as a UNC server; " + 251"use a drive path such as C:\\... instead" , 252); 253} 254if ( StringPrototypeCharCodeAt ( host , 0 ) === 91 ) { // [ 255if ( StringPrototypeCharCodeAt ( host , host . length - 1 ) !== 93 ) { // ] 256throw new TypeError ( "toFileUrl received an invalid IPv6 UNC server" ); 257} 258for ( let i = 1 ; i < host . length - 1 ; i ++ ) { 259const code = StringPrototypeCharCodeAt ( host , i ); 260const valid = ( code >= 48 && code <= 57 ) || 261( code >= 65 && code <= 70 ) || ( code >= 97 && code <= 102 ) || 262code === 46 || code === 58 || code === 37 ; 263if ( ! valid ) { 264throw new TypeError ( "toFileUrl received an invalid IPv6 UNC server" ); 265} 266} 267return host ; 268} 269for ( let i = 0 ; i < host . length ; i ++ ) { 270const code = StringPrototypeCharCodeAt ( host , i ); 271if ( 272code < 33 || code === 127 || code === 47 || code === 58 || // controls, space, DEL, / : 273code === 63 || code === 64 || code === 91 || code === 93 || // ? @ [ ] 274code === 35 || code === 37 // # % 275) { 276throw new TypeError ( `toFileUrl received an invalid UNC server " ${ host } "` ); 277} 278} 279return host ; 280} 281 282// The URL parser lowercases hosts and applies IDNA itself; confirm the UNC 283// server survived construction instead of collapsing (notably to localhost, 284// which file URLs treat as "no host"). 285function checkUncUrl ( url :AvrilURL ) :void { 286const host = getHostname ( url ); 287if ( host === "" || StringPrototypeToLowerCase ( host ) === "localhost" ) { 288throw new TypeError ( "toFileUrl lost the UNC server to URL normalization" ); 289} 290} 291 292function finishFileUrl ( href :string ) :AvrilURL { 293try { 294return new AvrilURL ( href ); 295} catch { 296throw new TypeError ( "Path could not be converted to a file: URL" ); 297} 298} 299 300function checkToFileUrlInput ( path :unknown , windows :boolean ) : assertspath isstring { 301validateString ( path , "path" ); 302if ( StringPrototypeIncludes ( path , "\0" )) { 303throw new TypeError ( "File URL conversion does not accept NUL bytes in paths" ); 304} 305if ( ! StringPrototypeIsWellFormed ( path )) { 306throw new TypeError ( "File URL conversion does not accept lone surrogates in paths" ); 307} 308if ( windows ) rejectExtendedNamespace ( path ); 309} 310 311function posixToFileUrl ( path :unknown ) :AvrilURL { 312checkToFileUrlInput ( path , false ); 313if ( StringPrototypeCharCodeAt ( path , 0 ) !== CHAR_FORWARD_SLASH ) { 314throw new TypeError ( "toFileUrl requires an absolute POSIX path" ); 315} 316return finishFileUrl ( `file:// ${ encodeAbsolutePath ( path )} ` ); 317} 318 319function win32ToFileUrl ( path :unknown ) :AvrilURL { 320checkToFileUrlInput ( path , true ); 321const unified = ArrayPrototypeJoin ( splitOnChar ( path , CHAR_BACKWARD_SLASH ), "/" ); 322const hadTrailing = StringPrototypeCharCodeAt ( unified , unified . length - 1 ) === CHAR_FORWARD_SLASH ; 323// Clamp `.`/`..` lexically so they cannot cross a drive or share root; the 324// URL parser below does not know the UNC share boundary. 325const n = ArrayPrototypeJoin ( splitOnChar ( win32Base . normalize ( unified ), CHAR_BACKWARD_SLASH ), "/" ); 326if ( 327StringPrototypeCharCodeAt ( n , 0 ) === CHAR_FORWARD_SLASH && 328StringPrototypeCharCodeAt ( n , 1 ) === CHAR_FORWARD_SLASH 329) { 330const end = StringPrototypeIndexOf ( n , "/" , 2 ); 331if ( end === - 1 ) { 332throw new TypeError ( "toFileUrl requires an absolute Windows path" ); 333} 334const rest = StringPrototypeSlice ( n , end ); 335const segEnd = StringPrototypeIndexOf ( rest , "/" , 1 ); 336const share = segEnd === - 1 ?StringPrototypeSlice ( rest , 1 ) :StringPrototypeSlice ( rest , 1 , segEnd ); 337// A bare, dot, or dot-dot server child names no share: normalizing it 338// away would silently retarget the URL at another share or the server. 339if ( share === "" || share === "." || share === ".." ) { 340throw new TypeError ( "toFileUrl requires a complete UNC share path" ); 341} 342const rootEnd = end + 1 + share . length ; 343// Normalization appends a separator to a bare share root; keep explicit 344// trailing slashes only when the input had one. 345let out = n ; 346if ( ! hadTrailing && out . length > rootEnd && StringPrototypeCharCodeAt ( out , out . length - 1 ) === CHAR_FORWARD_SLASH ) { 347out = StringPrototypeSlice ( out , 0 , out . length - 1 ); 348} 349const href = `file:// ${ checkUncHost ( StringPrototypeSlice ( n , 2 , end ))} ${ encodeAbsolutePath ( StringPrototypeSlice ( out , end ))} ` ; 350const url = finishFileUrl ( href ); 351checkUncUrl ( url ); 352return url ; 353} 354if ( 355n . length >= 3 && isDriveLetter ( StringPrototypeCharCodeAt ( n , 0 )) && 356StringPrototypeCharCodeAt ( n , 1 ) === 58 && // : 357StringPrototypeCharCodeAt ( n , 2 ) === CHAR_FORWARD_SLASH 358) { 359return finishFileUrl ( `file:/// ${ StringPrototypeSlice ( n , 0 , 2 )} ${ encodeAbsolutePath ( StringPrototypeSlice ( n , 2 ))} ` ); 360} 361// Drive-relative (`C:foo`), root-only (`\\foo`), and relative paths all 362// name no complete absolute location without consulting the process 363// working directory, which URL conversion deliberately never does. 364throw new TypeError ( "toFileUrl requires an absolute Windows path" ); 365} 366 367export { posixFromFileUrl , posixToFileUrl , win32FromFileUrl , win32ToFileUrl };