github.com/goreleaser/nfpm/v2@v2.44.0/files/files.go (about)

     1  package files
     2  
     3  import (
     4  	"fmt"
     5  	"io/fs"
     6  	"os"
     7  	"path/filepath"
     8  	"sort"
     9  	"strconv"
    10  	"strings"
    11  	"time"
    12  
    13  	"github.com/goreleaser/nfpm/v2/internal/glob"
    14  )
    15  
    16  const (
    17  	// TypeFile is the type of a regular file. This is also the type that is
    18  	// implied when no type is specified.
    19  	TypeFile = "file"
    20  	// TypeDir is the type of a directory that is explicitly added in order to
    21  	// declare ownership or non-standard permission.
    22  	TypeDir = "dir"
    23  	/// TypeImplicitDir is the type of a directory that is implicitly added as a
    24  	//parent of a file.
    25  	TypeImplicitDir = "implicit dir"
    26  	// TypeTree is the type of a whole directory tree structure.
    27  	TypeTree = "tree"
    28  	// TypeSymlink is the type of a symlink that is created at the destination
    29  	// path and points to the source path.
    30  	TypeSymlink = "symlink"
    31  	// TypeConfig is the type of a configuration file that may be changed by the
    32  	// user of the package.
    33  	TypeConfig = "config"
    34  	// TypeConfigNoReplace is like TypeConfig with an added noreplace directive
    35  	// that is respected by RPM-based distributions.
    36  	// For all other package formats it is handled exactly like TypeConfig.
    37  	TypeConfigNoReplace = "config|noreplace"
    38  	// TypeConfigMissingOK is like TypeConfig with an added missingok directive
    39  	// that is respected by RPM-based distributions.
    40  	// For all other package formats it is handled exactly like TypeConfig.
    41  	TypeConfigMissingOK = "config|missingok"
    42  	// TypeGhost is the type of an RPM ghost file which is ignored by other packagers.
    43  	TypeRPMGhost = "ghost"
    44  	// TypeRPMDoc is the type of an RPM doc file which is ignored by other packagers.
    45  	TypeRPMDoc = "doc"
    46  	// TypeRPMLicence is the type of an RPM licence file which is ignored by other packagers.
    47  	TypeRPMLicence = "licence"
    48  	// TypeRPMLicense a different spelling of TypeRPMLicence.
    49  	TypeRPMLicense = "license"
    50  	// TypeRPMReadme is the type of an RPM readme file which is ignored by other packagers.
    51  	TypeRPMReadme = "readme"
    52  	// TypeDebChangelog is the type of a Debian changelog archive file which is
    53  	// ignored by other packagers. This type should never be set for a content
    54  	// entry as it is automatically added when a changelog is configred.
    55  	TypeDebChangelog = "debian changelog"
    56  )
    57  
    58  // Content describes the source and destination
    59  // of one file to copy into a package.
    60  type Content struct {
    61  	Source      string           `yaml:"src,omitempty" json:"src,omitempty"`
    62  	Destination string           `yaml:"dst" json:"dst"`
    63  	Type        string           `yaml:"type,omitempty" json:"type,omitempty" jsonschema:"enum=symlink,enum=ghost,enum=config,enum=config|noreplace,enum=dir,enum=tree,enum=,default="`
    64  	Packager    string           `yaml:"packager,omitempty" json:"packager,omitempty"`
    65  	FileInfo    *ContentFileInfo `yaml:"file_info,omitempty" json:"file_info,omitempty"`
    66  	Expand      bool             `yaml:"expand,omitempty" json:"expand,omitempty"`
    67  }
    68  
    69  type ContentFileInfo struct {
    70  	Owner string      `yaml:"owner,omitempty" json:"owner,omitempty"`
    71  	Group string      `yaml:"group,omitempty" json:"group,omitempty"`
    72  	Mode  os.FileMode `yaml:"mode,omitempty" json:"mode,omitempty"`
    73  	MTime time.Time   `yaml:"mtime,omitempty" json:"mtime,omitempty"`
    74  	Size  int64       `yaml:"-" json:"-"`
    75  }
    76  
    77  // Contents list of Content to process.
    78  type Contents []*Content
    79  
    80  func (c Contents) Len() int {
    81  	return len(c)
    82  }
    83  
    84  func (c Contents) Swap(i, j int) {
    85  	c[i], c[j] = c[j], c[i]
    86  }
    87  
    88  func (c Contents) Less(i, j int) bool {
    89  	a, b := c[i], c[j]
    90  
    91  	if a.Destination != b.Destination {
    92  		return a.Destination < b.Destination
    93  	}
    94  
    95  	if a.Type != b.Type {
    96  		return a.Type < b.Type
    97  	}
    98  
    99  	return a.Packager < b.Packager
   100  }
   101  
   102  func (c Contents) ContainsDestination(dst string) bool {
   103  	for _, content := range c {
   104  		if strings.TrimRight(content.Destination, "/") == strings.TrimRight(dst, "/") {
   105  			return true
   106  		}
   107  	}
   108  
   109  	return false
   110  }
   111  
   112  func (c *Content) WithFileInfoDefaults(umask fs.FileMode, mtime time.Time) *Content {
   113  	cc := &Content{
   114  		Source:      c.Source,
   115  		Destination: c.Destination,
   116  		Type:        c.Type,
   117  		Packager:    c.Packager,
   118  		FileInfo:    c.FileInfo,
   119  	}
   120  	if cc.Type == "" {
   121  		cc.Type = TypeFile
   122  	}
   123  	if cc.FileInfo == nil {
   124  		cc.FileInfo = &ContentFileInfo{}
   125  	}
   126  	if cc.FileInfo.Owner == "" {
   127  		cc.FileInfo.Owner = "root"
   128  	}
   129  	if cc.FileInfo.Group == "" {
   130  		cc.FileInfo.Group = "root"
   131  	}
   132  	if (cc.Type == TypeDir || cc.Type == TypeImplicitDir) && cc.FileInfo.Mode == 0 {
   133  		cc.FileInfo.Mode = 0o755
   134  	}
   135  	if cc.FileInfo.MTime.IsZero() {
   136  		cc.FileInfo.MTime = mtime
   137  	}
   138  
   139  	// determine if we still need info
   140  	fileInfoAlreadyComplete := (!cc.FileInfo.MTime.IsZero() &&
   141  		cc.FileInfo.Mode != 0 &&
   142  		(cc.FileInfo.Size != 0 || (cc.Type == TypeDir || cc.Type == TypeImplicitDir)))
   143  
   144  	// only stat source when we actually need more information
   145  	if cc.Source != "" && !fileInfoAlreadyComplete {
   146  		info, err := os.Stat(cc.Source)
   147  		if err == nil {
   148  			if cc.FileInfo.MTime.IsZero() {
   149  				// if we can stat the file and mtime not set, use original
   150  				// file's mtime
   151  				cc.FileInfo.MTime = info.ModTime()
   152  			}
   153  			if cc.FileInfo.Mode == 0 {
   154  				cc.FileInfo.Mode = info.Mode() &^ umask
   155  			}
   156  			cc.FileInfo.Size = info.Size()
   157  		}
   158  	}
   159  
   160  	// finally, if mtime is still 0, set time.Now()
   161  	if cc.FileInfo.MTime.IsZero() {
   162  		cc.FileInfo.MTime = time.Now()
   163  	}
   164  	return cc
   165  }
   166  
   167  // Name to part of the os.FileInfo interface
   168  func (c *Content) Name() string {
   169  	return c.Source
   170  }
   171  
   172  // Size to part of the os.FileInfo interface
   173  func (c *Content) Size() int64 {
   174  	return c.FileInfo.Size
   175  }
   176  
   177  // Mode to part of the os.FileInfo interface
   178  func (c *Content) Mode() os.FileMode {
   179  	return c.FileInfo.Mode
   180  }
   181  
   182  // ModTime to part of the os.FileInfo interface
   183  func (c *Content) ModTime() time.Time {
   184  	return c.FileInfo.MTime
   185  }
   186  
   187  // IsDir to part of the os.FileInfo interface
   188  func (c *Content) IsDir() bool {
   189  	return c.Type == TypeDir || c.Type == TypeImplicitDir
   190  }
   191  
   192  // Sys to part of the os.FileInfo interface
   193  func (c *Content) Sys() any {
   194  	return nil
   195  }
   196  
   197  func (c *Content) String() string {
   198  	var properties []string
   199  	if c.Source != "" {
   200  		properties = append(properties, "src="+c.Source)
   201  	}
   202  	if c.Destination != "" {
   203  		properties = append(properties, "dst="+c.Destination)
   204  	}
   205  	if c.Type != "" {
   206  		properties = append(properties, "type="+c.Type)
   207  	}
   208  	if c.Packager != "" {
   209  		properties = append(properties, "packager="+c.Packager)
   210  	}
   211  	if c.FileInfo != nil {
   212  		if c.FileInfo.Owner != "" {
   213  			properties = append(properties, "owner="+c.FileInfo.Owner)
   214  		}
   215  		if c.FileInfo.Group != "" {
   216  			properties = append(properties, "group="+c.FileInfo.Group)
   217  		}
   218  		if c.Mode() != 0 {
   219  			properties = append(properties, "mode="+c.Mode().String())
   220  		}
   221  		if !c.ModTime().IsZero() {
   222  			properties = append(properties, "modtime="+c.ModTime().String())
   223  		}
   224  		properties = append(properties, "size="+strconv.Itoa(int(c.FileInfo.Size)))
   225  	}
   226  
   227  	return fmt.Sprintf("Content(%s)", strings.Join(properties, ","))
   228  }
   229  
   230  // PrepareForPackager performs the following steps to prepare the contents for
   231  // the provided packager:
   232  //
   233  //   - It filters out content that is irrelevant for the specified packager
   234  //   - It expands globs (if enabled) and file trees
   235  //   - It adds implicit directories (parent directories of files)
   236  //   - It adds ownership and other file information if not specified directly
   237  //   - It applies the given umask if the file does not have a specific mode
   238  //   - It normalizes content source paths to be unix style paths
   239  //   - It normalizes content destination paths to be absolute paths with a trailing
   240  //     slash if the entry is a directory
   241  //
   242  // If no packager is specified, only the files that are relevant for any
   243  // packager are considered.
   244  func PrepareForPackager(
   245  	rawContents Contents,
   246  	umask fs.FileMode,
   247  	packager string,
   248  	disableGlobbing bool,
   249  	mtime time.Time,
   250  ) (Contents, error) {
   251  	contentMap := make(map[string]*Content)
   252  
   253  	for _, content := range rawContents {
   254  		if !isRelevantForPackager(packager, content) {
   255  			continue
   256  		}
   257  
   258  		switch content.Type {
   259  		case TypeDir:
   260  			// implicit directories at the same destination can just be overwritten
   261  			presentContent, destinationOccupied := contentMap[NormalizeAbsoluteDirPath(content.Destination)]
   262  			if destinationOccupied && presentContent.Type != TypeImplicitDir {
   263  				return nil, contentCollisionError(content, presentContent)
   264  			}
   265  
   266  			err := addParents(contentMap, content.Destination, mtime, nil)
   267  			if err != nil {
   268  				return nil, err
   269  			}
   270  
   271  			cc := content.WithFileInfoDefaults(umask, mtime)
   272  			cc.Source = ToNixPath(cc.Source)
   273  			cc.Destination = NormalizeAbsoluteDirPath(cc.Destination)
   274  			contentMap[cc.Destination] = cc
   275  		case TypeImplicitDir:
   276  			// if there's an implicit directory, the contents probably already
   277  			// have been expanded so we can just ignore it, it will be created
   278  			// by another content element again anyway
   279  		case TypeRPMGhost, TypeSymlink, TypeRPMDoc, TypeRPMLicence, TypeRPMLicense, TypeRPMReadme, TypeDebChangelog:
   280  			presentContent, destinationOccupied := contentMap[NormalizeAbsoluteFilePath(content.Destination)]
   281  			if destinationOccupied {
   282  				return nil, contentCollisionError(content, presentContent)
   283  			}
   284  
   285  			err := addParents(contentMap, content.Destination, mtime, nil)
   286  			if err != nil {
   287  				return nil, err
   288  			}
   289  
   290  			cc := content.WithFileInfoDefaults(umask, mtime)
   291  			cc.Source = ToNixPath(cc.Source)
   292  			cc.Destination = NormalizeAbsoluteFilePath(cc.Destination)
   293  			contentMap[cc.Destination] = cc
   294  		case TypeTree:
   295  			err := addTree(contentMap, content, umask, mtime)
   296  			if err != nil {
   297  				return nil, fmt.Errorf("add tree: %w", err)
   298  			}
   299  		case TypeConfig, TypeConfigNoReplace, TypeConfigMissingOK, TypeFile, "":
   300  			globbed, err := glob.Glob(
   301  				filepath.ToSlash(content.Source),
   302  				filepath.ToSlash(content.Destination),
   303  				disableGlobbing,
   304  			)
   305  			if err != nil {
   306  				return nil, err
   307  			}
   308  
   309  			if err := addGlobbedFiles(contentMap, globbed, content, umask, mtime); err != nil {
   310  				return nil, fmt.Errorf("add globbed files from %q: %w", content.Source, err)
   311  			}
   312  		default:
   313  			return nil, fmt.Errorf("invalid content type: %s", content.Type)
   314  		}
   315  	}
   316  
   317  	res := make(Contents, 0, len(contentMap))
   318  
   319  	for _, content := range contentMap {
   320  		res = append(res, content)
   321  	}
   322  
   323  	sort.Sort(res)
   324  
   325  	return res, nil
   326  }
   327  
   328  func isRelevantForPackager(packager string, content *Content) bool {
   329  	if packager == "" {
   330  		return true
   331  	}
   332  
   333  	if content.Packager != "" && content.Packager != packager {
   334  		return false
   335  	}
   336  
   337  	if packager != "rpm" &&
   338  		(content.Type == TypeRPMDoc || content.Type == TypeRPMLicence ||
   339  			content.Type == TypeRPMLicense || content.Type == TypeRPMReadme ||
   340  			content.Type == TypeRPMGhost) {
   341  		return false
   342  	}
   343  
   344  	if packager != "deb" && content.Type == TypeDebChangelog {
   345  		return false
   346  	}
   347  
   348  	return true
   349  }
   350  
   351  func addParents(contentMap map[string]*Content, path string, mtime time.Time, fileInfo *ContentFileInfo) error {
   352  	for _, parent := range sortedParents(path) {
   353  		parent = NormalizeAbsoluteDirPath(parent)
   354  		// check for content collision and just overwrite previously created
   355  		// implicit directories
   356  		c, ok := contentMap[parent]
   357  		if ok {
   358  			// either we already created this directory as an explicit directory
   359  			// or as an implicit directory of another file
   360  			if c.Type == TypeDir || c.Type == TypeImplicitDir {
   361  				continue
   362  			}
   363  
   364  			return contentCollisionError(&Content{
   365  				Type:        "parent directory for " + path,
   366  				Destination: parent,
   367  			}, c)
   368  		}
   369  
   370  		owner := "root"
   371  		group := "root"
   372  
   373  		// Use provided ownership for directories that are not owned by the filesystem
   374  		if fileInfo != nil && !ownedByFilesystem(parent) {
   375  			if fileInfo.Owner != "" {
   376  				owner = fileInfo.Owner
   377  			}
   378  			if fileInfo.Group != "" {
   379  				group = fileInfo.Group
   380  			}
   381  		}
   382  
   383  		contentMap[parent] = &Content{
   384  			Destination: parent,
   385  			Type:        TypeImplicitDir,
   386  			FileInfo: &ContentFileInfo{
   387  				Owner: owner,
   388  				Group: group,
   389  				Mode:  0o755,
   390  				MTime: mtime,
   391  			},
   392  		}
   393  	}
   394  
   395  	return nil
   396  }
   397  
   398  func sortedParents(dst string) []string {
   399  	paths := []string{}
   400  	base := strings.Trim(dst, "/")
   401  	for {
   402  		base = filepath.Dir(base)
   403  		if base == "." {
   404  			break
   405  		}
   406  		paths = append(paths, ToNixPath(base))
   407  	}
   408  
   409  	// reverse in place
   410  	for i := len(paths)/2 - 1; i >= 0; i-- {
   411  		oppositeIndex := len(paths) - 1 - i
   412  		paths[i], paths[oppositeIndex] = paths[oppositeIndex], paths[i]
   413  	}
   414  
   415  	return paths
   416  }
   417  
   418  func addGlobbedFiles(
   419  	all map[string]*Content,
   420  	globbed map[string]string,
   421  	origFile *Content,
   422  	umask fs.FileMode,
   423  	mtime time.Time,
   424  ) error {
   425  	for src, dst := range globbed {
   426  		dst = NormalizeAbsoluteFilePath(dst)
   427  		presentContent, destinationOccupied := all[dst]
   428  		if destinationOccupied {
   429  			c := *origFile
   430  			c.Destination = dst
   431  			return contentCollisionError(&c, presentContent)
   432  		}
   433  
   434  		if err := addParents(all, dst, mtime, origFile.FileInfo); err != nil {
   435  			return err
   436  		}
   437  
   438  		// if the file has a FileInfo, we need to copy it but recalculate its size
   439  		newFileInfo := origFile.FileInfo
   440  		if newFileInfo != nil {
   441  			newFileInfoVal := *newFileInfo
   442  			newFileInfoVal.Size = 0
   443  			newFileInfo = &newFileInfoVal
   444  		}
   445  
   446  		newFile := (&Content{
   447  			Destination: NormalizeAbsoluteFilePath(dst),
   448  			Source:      ToNixPath(src),
   449  			Type:        origFile.Type,
   450  			FileInfo:    newFileInfo,
   451  			Packager:    origFile.Packager,
   452  		}).WithFileInfoDefaults(umask, mtime)
   453  		if dst, err := os.Readlink(src); err == nil {
   454  			newFile.Source = dst
   455  			newFile.Type = TypeSymlink
   456  		}
   457  
   458  		all[dst] = newFile
   459  	}
   460  
   461  	return nil
   462  }
   463  
   464  func addTree(
   465  	all map[string]*Content,
   466  	tree *Content,
   467  	umask os.FileMode,
   468  	mtime time.Time,
   469  ) error {
   470  	if tree.Destination != "/" && tree.Destination != "" {
   471  		presentContent, destinationOccupied := all[NormalizeAbsoluteDirPath(tree.Destination)]
   472  		if destinationOccupied && presentContent.Type != TypeImplicitDir {
   473  			return contentCollisionError(tree, presentContent)
   474  		}
   475  	}
   476  
   477  	err := addParents(all, tree.Destination, mtime, tree.FileInfo)
   478  	if err != nil {
   479  		return err
   480  	}
   481  
   482  	return filepath.WalkDir(tree.Source, func(path string, d fs.DirEntry, err error) error {
   483  		if err != nil {
   484  			return err
   485  		}
   486  
   487  		relPath, err := filepath.Rel(tree.Source, path)
   488  		if err != nil {
   489  			return err
   490  		}
   491  
   492  		destination := filepath.Join(tree.Destination, relPath)
   493  
   494  		c := &Content{
   495  			FileInfo: &ContentFileInfo{},
   496  		}
   497  		if tree.FileInfo != nil && !ownedByFilesystem(c.Destination) {
   498  			c.FileInfo.Owner = tree.FileInfo.Owner
   499  			c.FileInfo.Group = tree.FileInfo.Group
   500  		}
   501  
   502  		switch {
   503  		case d.IsDir():
   504  			info, err := d.Info()
   505  			if err != nil {
   506  				return fmt.Errorf("get directory information: %w", err)
   507  			}
   508  
   509  			c.Type = TypeDir
   510  			c.Destination = NormalizeAbsoluteDirPath(destination)
   511  			c.FileInfo.Mode = info.Mode() &^ umask
   512  			c.FileInfo.MTime = info.ModTime()
   513  			if ownedByFilesystem(c.Destination) {
   514  				c.Type = TypeImplicitDir
   515  			}
   516  		case d.Type()&os.ModeSymlink != 0:
   517  			linkDestination, err := os.Readlink(path)
   518  			if err != nil {
   519  				return err
   520  			}
   521  
   522  			c.Type = TypeSymlink
   523  			c.Source = filepath.ToSlash(strings.TrimPrefix(linkDestination, filepath.VolumeName(linkDestination)))
   524  			c.Destination = NormalizeAbsoluteFilePath(destination)
   525  		default:
   526  			c.Type = TypeFile
   527  			c.Source = path
   528  			c.Destination = NormalizeAbsoluteFilePath(destination)
   529  			c.FileInfo.Mode = d.Type() &^ umask
   530  		}
   531  
   532  		if tree.FileInfo != nil && tree.FileInfo.Mode != 0 && c.Type != TypeSymlink {
   533  			c.FileInfo.Mode = tree.FileInfo.Mode
   534  		}
   535  
   536  		all[c.Destination] = c.WithFileInfoDefaults(umask, mtime)
   537  
   538  		return nil
   539  	})
   540  }
   541  
   542  var ErrContentCollision = fmt.Errorf("content collision")
   543  
   544  func contentCollisionError(newc *Content, present *Content) error {
   545  	var presentSource string
   546  	if present.Source != "" {
   547  		presentSource = " with source " + present.Source
   548  	}
   549  
   550  	return fmt.Errorf("adding %s at destination %s: "+
   551  		"%s%s is already present at this destination: %w",
   552  		newc.Type, newc.Destination, present.Type, presentSource, ErrContentCollision,
   553  	)
   554  }
   555  
   556  // ToNixPath converts the given path to a nix-style path.
   557  //
   558  // Windows-style path separators are considered escape
   559  // characters by some libraries, which can cause issues.
   560  func ToNixPath(path string) string {
   561  	return filepath.ToSlash(filepath.Clean(path))
   562  }
   563  
   564  // As relative path converts a path to an explicitly relative path starting with
   565  // a dot (e.g. it converts /foo -> ./foo and foo -> ./foo).
   566  func AsExplicitRelativePath(path string) string {
   567  	return "./" + AsRelativePath(path)
   568  }
   569  
   570  // AsRelativePath converts a path to a relative path without a "./" prefix. This
   571  // function leaves trailing slashes to indicate that the path refers to a
   572  // directory, and converts the path to Unix path.
   573  func AsRelativePath(path string) string {
   574  	cleanedPath := strings.TrimLeft(ToNixPath(path), "/")
   575  	if len(cleanedPath) > 1 && strings.HasSuffix(path, "/") {
   576  		return cleanedPath + "/"
   577  	}
   578  	return cleanedPath
   579  }
   580  
   581  // NormalizeAbsoluteFilePath returns an absolute cleaned path separated by
   582  // slashes.
   583  func NormalizeAbsoluteFilePath(src string) string {
   584  	return ToNixPath(filepath.Join("/", src))
   585  }
   586  
   587  // normalizeFirPath is linke NormalizeAbsoluteFilePath with a trailing slash.
   588  func NormalizeAbsoluteDirPath(path string) string {
   589  	return NormalizeAbsoluteFilePath(strings.TrimRight(path, "/")) + "/"
   590  }