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  }