github.com/evanw/esbuild@v0.21.4/internal/fs/fs_zip.go (about) 1 package fs 2 3 // The Yarn package manager (https://yarnpkg.com/) has a custom installation 4 // strategy called "Plug'n'Play" where they install packages as zip files 5 // instead of directory trees, and then modify node to treat zip files like 6 // directories. This reduces package installation time because Yarn now only 7 // has to copy a single file per package instead of a whole directory tree. 8 // However, it introduces overhead at run-time because the virtual file system 9 // is written in JavaScript. 10 // 11 // This file contains esbuild's implementation of the behavior that treats zip 12 // files like directories. It implements the "FS" interface and wraps an inner 13 // "FS" interface that treats zip files like files. That way it can run both on 14 // a real file system and a mock file system. 15 // 16 // This file also implements another Yarn-specific behavior where certain paths 17 // containing the special path segments "__virtual__" or "$$virtual" have some 18 // unusual behavior. See the code below for details. 19 20 import ( 21 "archive/zip" 22 "io/ioutil" 23 "strconv" 24 "strings" 25 "sync" 26 "syscall" 27 ) 28 29 type zipFS struct { 30 inner FS 31 32 zipFilesMutex sync.Mutex 33 zipFiles map[string]*zipFile 34 } 35 36 type zipFile struct { 37 reader *zip.ReadCloser 38 err error 39 40 dirs map[string]*compressedDir 41 files map[string]*compressedFile 42 wait sync.WaitGroup 43 } 44 45 type compressedDir struct { 46 entries map[string]EntryKind 47 path string 48 49 // Compatible entries are decoded lazily 50 mutex sync.Mutex 51 dirEntries DirEntries 52 } 53 54 type compressedFile struct { 55 compressed *zip.File 56 57 // The file is decompressed lazily 58 mutex sync.Mutex 59 contents string 60 err error 61 wasRead bool 62 } 63 64 func (fs *zipFS) checkForZip(path string, kind EntryKind) (*zipFile, string) { 65 var zipPath string 66 var pathTail string 67 68 // Do a quick check for a ".zip" in the path at all 69 path = strings.ReplaceAll(path, "\\", "/") 70 if i := strings.Index(path, ".zip/"); i != -1 { 71 zipPath = path[:i+len(".zip")] 72 pathTail = path[i+len(".zip/"):] 73 } else if kind == DirEntry && strings.HasSuffix(path, ".zip") { 74 zipPath = path 75 } else { 76 return nil, "" 77 } 78 79 // If there is one, then check whether it's a file on the file system or not 80 fs.zipFilesMutex.Lock() 81 archive := fs.zipFiles[zipPath] 82 if archive != nil { 83 fs.zipFilesMutex.Unlock() 84 archive.wait.Wait() 85 } else { 86 archive = &zipFile{} 87 archive.wait.Add(1) 88 fs.zipFiles[zipPath] = archive 89 fs.zipFilesMutex.Unlock() 90 defer archive.wait.Done() 91 92 // Try reading the zip archive if it's not in the cache 93 tryToReadZipArchive(zipPath, archive) 94 } 95 96 if archive.err != nil { 97 return nil, "" 98 } 99 return archive, pathTail 100 } 101 102 func tryToReadZipArchive(zipPath string, archive *zipFile) { 103 reader, err := zip.OpenReader(zipPath) 104 if err != nil { 105 archive.err = err 106 return 107 } 108 109 dirs := make(map[string]*compressedDir) 110 files := make(map[string]*compressedFile) 111 seeds := []string{} 112 113 // Build an index of all files in the archive 114 for _, file := range reader.File { 115 baseName := strings.TrimSuffix(file.Name, "/") 116 dirPath := "" 117 if slash := strings.LastIndexByte(baseName, '/'); slash != -1 { 118 dirPath = baseName[:slash] 119 baseName = baseName[slash+1:] 120 } 121 if file.FileInfo().IsDir() { 122 // Handle a directory 123 lowerDir := strings.ToLower(dirPath) 124 if _, ok := dirs[lowerDir]; !ok { 125 dir := &compressedDir{ 126 path: dirPath, 127 entries: make(map[string]EntryKind), 128 } 129 130 // List the same directory both with and without the slash 131 dirs[lowerDir] = dir 132 dirs[lowerDir+"/"] = dir 133 seeds = append(seeds, lowerDir) 134 } 135 } else { 136 // Handle a file 137 files[strings.ToLower(file.Name)] = &compressedFile{compressed: file} 138 lowerDir := strings.ToLower(dirPath) 139 dir, ok := dirs[lowerDir] 140 if !ok { 141 dir = &compressedDir{ 142 path: dirPath, 143 entries: make(map[string]EntryKind), 144 } 145 146 // List the same directory both with and without the slash 147 dirs[lowerDir] = dir 148 dirs[lowerDir+"/"] = dir 149 seeds = append(seeds, lowerDir) 150 } 151 dir.entries[baseName] = FileEntry 152 } 153 } 154 155 // Populate child directories 156 for _, baseName := range seeds { 157 for baseName != "" { 158 dirPath := "" 159 if slash := strings.LastIndexByte(baseName, '/'); slash != -1 { 160 dirPath = baseName[:slash] 161 baseName = baseName[slash+1:] 162 } 163 lowerDir := strings.ToLower(dirPath) 164 dir, ok := dirs[lowerDir] 165 if !ok { 166 dir = &compressedDir{ 167 path: dirPath, 168 entries: make(map[string]EntryKind), 169 } 170 171 // List the same directory both with and without the slash 172 dirs[lowerDir] = dir 173 dirs[lowerDir+"/"] = dir 174 } 175 dir.entries[baseName] = DirEntry 176 baseName = dirPath 177 } 178 } 179 180 archive.dirs = dirs 181 archive.files = files 182 archive.reader = reader 183 } 184 185 func (fs *zipFS) ReadDirectory(path string) (entries DirEntries, canonicalError error, originalError error) { 186 path = mangleYarnPnPVirtualPath(path) 187 188 entries, canonicalError, originalError = fs.inner.ReadDirectory(path) 189 190 // Only continue if reading this path as a directory caused an error that's 191 // consistent with trying to read a zip file as a directory. Note that EINVAL 192 // is produced by the file system in Go's WebAssembly implementation. 193 if canonicalError != syscall.ENOENT && canonicalError != syscall.ENOTDIR && canonicalError != syscall.EINVAL { 194 return 195 } 196 197 // If the directory doesn't exist, try reading from an enclosing zip archive 198 zip, pathTail := fs.checkForZip(path, DirEntry) 199 if zip == nil { 200 return 201 } 202 203 // Does the zip archive have this directory? 204 dir, ok := zip.dirs[strings.ToLower(pathTail)] 205 if !ok { 206 return DirEntries{}, syscall.ENOENT, syscall.ENOENT 207 } 208 209 // Check whether it has already been converted 210 dir.mutex.Lock() 211 defer dir.mutex.Unlock() 212 if dir.dirEntries.data != nil { 213 return dir.dirEntries, nil, nil 214 } 215 216 // Otherwise, fill in the entries 217 dir.dirEntries = DirEntries{dir: path, data: make(map[string]*Entry, len(dir.entries))} 218 for name, kind := range dir.entries { 219 dir.dirEntries.data[strings.ToLower(name)] = &Entry{ 220 dir: path, 221 base: name, 222 kind: kind, 223 } 224 } 225 226 return dir.dirEntries, nil, nil 227 } 228 229 func (fs *zipFS) ReadFile(path string) (contents string, canonicalError error, originalError error) { 230 path = mangleYarnPnPVirtualPath(path) 231 232 contents, canonicalError, originalError = fs.inner.ReadFile(path) 233 if canonicalError != syscall.ENOENT { 234 return 235 } 236 237 // If the file doesn't exist, try reading from an enclosing zip archive 238 zip, pathTail := fs.checkForZip(path, FileEntry) 239 if zip == nil { 240 return 241 } 242 243 // Does the zip archive have this file? 244 file, ok := zip.files[strings.ToLower(pathTail)] 245 if !ok { 246 return "", syscall.ENOENT, syscall.ENOENT 247 } 248 249 // Check whether it has already been read 250 file.mutex.Lock() 251 defer file.mutex.Unlock() 252 if file.wasRead { 253 return file.contents, file.err, file.err 254 } 255 file.wasRead = true 256 257 // If not, try to open it 258 reader, err := file.compressed.Open() 259 if err != nil { 260 file.err = err 261 return "", err, err 262 } 263 defer reader.Close() 264 265 // Then try to read it 266 bytes, err := ioutil.ReadAll(reader) 267 if err != nil { 268 file.err = err 269 return "", err, err 270 } 271 272 file.contents = string(bytes) 273 return file.contents, nil, nil 274 } 275 276 func (fs *zipFS) OpenFile(path string) (result OpenedFile, canonicalError error, originalError error) { 277 path = mangleYarnPnPVirtualPath(path) 278 279 result, canonicalError, originalError = fs.inner.OpenFile(path) 280 return 281 } 282 283 func (fs *zipFS) ModKey(path string) (modKey ModKey, err error) { 284 path = mangleYarnPnPVirtualPath(path) 285 286 modKey, err = fs.inner.ModKey(path) 287 return 288 } 289 290 func (fs *zipFS) IsAbs(path string) bool { 291 return fs.inner.IsAbs(path) 292 } 293 294 func (fs *zipFS) Abs(path string) (string, bool) { 295 return fs.inner.Abs(path) 296 } 297 298 func (fs *zipFS) Dir(path string) string { 299 if prefix, suffix, ok := ParseYarnPnPVirtualPath(path); ok && suffix == "" { 300 return prefix 301 } 302 return fs.inner.Dir(path) 303 } 304 305 func (fs *zipFS) Base(path string) string { 306 return fs.inner.Base(path) 307 } 308 309 func (fs *zipFS) Ext(path string) string { 310 return fs.inner.Ext(path) 311 } 312 313 func (fs *zipFS) Join(parts ...string) string { 314 return fs.inner.Join(parts...) 315 } 316 317 func (fs *zipFS) Cwd() string { 318 return fs.inner.Cwd() 319 } 320 321 func (fs *zipFS) Rel(base string, target string) (string, bool) { 322 return fs.inner.Rel(base, target) 323 } 324 325 func (fs *zipFS) EvalSymlinks(path string) (string, bool) { 326 return fs.inner.EvalSymlinks(path) 327 } 328 329 func (fs *zipFS) kind(dir string, base string) (symlink string, kind EntryKind) { 330 return fs.inner.kind(dir, base) 331 } 332 333 func (fs *zipFS) WatchData() WatchData { 334 return fs.inner.WatchData() 335 } 336 337 func ParseYarnPnPVirtualPath(path string) (string, string, bool) { 338 i := 0 339 340 for { 341 start := i 342 slash := strings.IndexAny(path[i:], "/\\") 343 if slash == -1 { 344 break 345 } 346 i += slash + 1 347 348 // Replace the segments "__virtual__/<segment>/<n>" with N times the ".." 349 // operation. Note: The "__virtual__" folder name appeared with Yarn 3.0. 350 // Earlier releases used "$$virtual", but it was changed after discovering 351 // that this pattern triggered bugs in software where paths were used as 352 // either regexps or replacement. For example, "$$" found in the second 353 // parameter of "String.prototype.replace" silently turned into "$". 354 if segment := path[start : i-1]; segment == "__virtual__" || segment == "$$virtual" { 355 if slash := strings.IndexAny(path[i:], "/\\"); slash != -1 { 356 var count string 357 var suffix string 358 j := i + slash + 1 359 360 // Find the range of the count 361 if slash := strings.IndexAny(path[j:], "/\\"); slash != -1 { 362 count = path[j : j+slash] 363 suffix = path[j+slash:] 364 } else { 365 count = path[j:] 366 } 367 368 // Parse the count 369 if n, err := strconv.ParseInt(count, 10, 64); err == nil { 370 prefix := path[:start] 371 372 // Apply N times the ".." operator 373 for n > 0 && (strings.HasSuffix(prefix, "/") || strings.HasSuffix(prefix, "\\")) { 374 slash := strings.LastIndexAny(prefix[:len(prefix)-1], "/\\") 375 if slash == -1 { 376 break 377 } 378 prefix = prefix[:slash+1] 379 n-- 380 } 381 382 // Make sure the prefix and suffix work well when joined together 383 if suffix == "" && strings.IndexAny(prefix, "/\\") != strings.LastIndexAny(prefix, "/\\") { 384 prefix = prefix[:len(prefix)-1] 385 } else if prefix == "" { 386 prefix = "." 387 } else if strings.HasPrefix(suffix, "/") || strings.HasPrefix(suffix, "\\") { 388 suffix = suffix[1:] 389 } 390 391 return prefix, suffix, true 392 } 393 } 394 } 395 } 396 397 return "", "", false 398 } 399 400 func mangleYarnPnPVirtualPath(path string) string { 401 if prefix, suffix, ok := ParseYarnPnPVirtualPath(path); ok { 402 return prefix + suffix 403 } 404 return path 405 }