1
1
Fork 0
mirror of https://github.com/goreleaser/nfpm synced 2024-05-19 09:16:20 +02:00
nfpm/files/files.go
Oscar Linderholm e1ebfdad10
fix: honor SOURCE_DATE_EPOCH for files (#775)
If set, SOURCE_DATE_EPOCH will be honored for all content entries.
2024-01-30 07:59:30 -03:00

564 lines
16 KiB
Go

package files
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/goreleaser/nfpm/v2/internal/glob"
)
const (
// TypeFile is the type of a regular file. This is also the type that is
// implied when no type is specified.
TypeFile = "file"
// TypeDir is the type of a directory that is explicitly added in order to
// declare ownership or non-standard permission.
TypeDir = "dir"
/// TypeImplicitDir is the type of a directory that is implicitly added as a
//parent of a file.
TypeImplicitDir = "implicit dir"
// TypeTree is the type of a whole directory tree structure.
TypeTree = "tree"
// TypeSymlink is the type of a symlink that is created at the destination
// path and points to the source path.
TypeSymlink = "symlink"
// TypeConfig is the type of a configuration file that may be changed by the
// user of the package.
TypeConfig = "config"
// TypeConfigNoReplace is like TypeConfig with an added noreplace directive
// that is respected by RPM-based distributions. For all other packages it
// is handled exactly like TypeConfig.
TypeConfigNoReplace = "config|noreplace"
// TypeGhost is the type of an RPM ghost file which is ignored by other packagers.
TypeRPMGhost = "ghost"
// TypeRPMDoc is the type of an RPM doc file which is ignored by other packagers.
TypeRPMDoc = "doc"
// TypeRPMLicence is the type of an RPM licence file which is ignored by other packagers.
TypeRPMLicence = "licence"
// TypeRPMLicense a different spelling of TypeRPMLicence.
TypeRPMLicense = "license"
// TypeRPMReadme is the type of an RPM readme file which is ignored by other packagers.
TypeRPMReadme = "readme"
// TypeDebChangelog is the type of a Debian changelog archive file which is
// ignored by other packagers. This type should never be set for a content
// entry as it is automatically added when a changelog is configred.
TypeDebChangelog = "debian changelog"
)
// Content describes the source and destination
// of one file to copy into a package.
type Content struct {
Source string `yaml:"src,omitempty" json:"src,omitempty"`
Destination string `yaml:"dst" json:"dst"`
Type string `yaml:"type,omitempty" json:"type,omitempty" jsonschema:"enum=symlink,enum=ghost,enum=config,enum=config|noreplace,enum=dir,enum=tree,enum=,default="`
Packager string `yaml:"packager,omitempty" json:"packager,omitempty"`
FileInfo *ContentFileInfo `yaml:"file_info,omitempty" json:"file_info,omitempty"`
Expand bool `yaml:"expand,omitempty" json:"expand,omitempty"`
}
type ContentFileInfo struct {
Owner string `yaml:"owner,omitempty" json:"owner,omitempty"`
Group string `yaml:"group,omitempty" json:"group,omitempty"`
Mode os.FileMode `yaml:"mode,omitempty" json:"mode,omitempty"`
MTime time.Time `yaml:"mtime,omitempty" json:"mtime,omitempty"`
Size int64 `yaml:"-" json:"-"`
}
// Contents list of Content to process.
type Contents []*Content
func (c Contents) Len() int {
return len(c)
}
func (c Contents) Swap(i, j int) {
c[i], c[j] = c[j], c[i]
}
func (c Contents) Less(i, j int) bool {
a, b := c[i], c[j]
if a.Destination != b.Destination {
return a.Destination < b.Destination
}
if a.Type != b.Type {
return a.Type < b.Type
}
return a.Packager < b.Packager
}
func (c Contents) ContainsDestination(dst string) bool {
for _, content := range c {
if strings.TrimRight(content.Destination, "/") == strings.TrimRight(dst, "/") {
return true
}
}
return false
}
func (c *Content) WithFileInfoDefaults(umask fs.FileMode, mtime time.Time) *Content {
cc := &Content{
Source: c.Source,
Destination: c.Destination,
Type: c.Type,
Packager: c.Packager,
FileInfo: c.FileInfo,
}
if cc.Type == "" {
cc.Type = TypeFile
}
if cc.FileInfo == nil {
cc.FileInfo = &ContentFileInfo{}
}
if cc.FileInfo.Owner == "" {
cc.FileInfo.Owner = "root"
}
if cc.FileInfo.Group == "" {
cc.FileInfo.Group = "root"
}
if (cc.Type == TypeDir || cc.Type == TypeImplicitDir) && cc.FileInfo.Mode == 0 {
cc.FileInfo.Mode = 0o755
}
if cc.FileInfo.MTime.IsZero() {
cc.FileInfo.MTime = mtime
}
// determine if we still need info
fileInfoAlreadyComplete := (!cc.FileInfo.MTime.IsZero() &&
cc.FileInfo.Mode != 0 &&
(cc.FileInfo.Size != 0 || (cc.Type == TypeDir || cc.Type == TypeImplicitDir)))
// only stat source when we actually need more information
if cc.Source != "" && !fileInfoAlreadyComplete {
info, err := os.Stat(cc.Source)
if err == nil {
if cc.FileInfo.MTime.IsZero() {
cc.FileInfo.MTime = info.ModTime()
}
if cc.FileInfo.Mode == 0 {
cc.FileInfo.Mode = info.Mode() &^ umask
}
cc.FileInfo.Size = info.Size()
}
}
if cc.FileInfo.MTime.IsZero() {
cc.FileInfo.MTime = mtime
}
return cc
}
// Name to part of the os.FileInfo interface
func (c *Content) Name() string {
return c.Source
}
// Size to part of the os.FileInfo interface
func (c *Content) Size() int64 {
return c.FileInfo.Size
}
// Mode to part of the os.FileInfo interface
func (c *Content) Mode() os.FileMode {
return c.FileInfo.Mode
}
// ModTime to part of the os.FileInfo interface
func (c *Content) ModTime() time.Time {
return c.FileInfo.MTime
}
// IsDir to part of the os.FileInfo interface
func (c *Content) IsDir() bool {
return false
}
// Sys to part of the os.FileInfo interface
func (c *Content) Sys() interface{} {
return nil
}
func (c *Content) String() string {
var properties []string
if c.Source != "" {
properties = append(properties, "src="+c.Source)
}
if c.Destination != "" {
properties = append(properties, "dst="+c.Destination)
}
if c.Type != "" {
properties = append(properties, "type="+c.Type)
}
if c.Packager != "" {
properties = append(properties, "packager="+c.Packager)
}
if c.FileInfo != nil {
if c.FileInfo.Owner != "" {
properties = append(properties, "owner="+c.FileInfo.Owner)
}
if c.FileInfo.Group != "" {
properties = append(properties, "group="+c.FileInfo.Group)
}
if c.Mode() != 0 {
properties = append(properties, "mode="+c.Mode().String())
}
if !c.ModTime().IsZero() {
properties = append(properties, "modtime="+c.ModTime().String())
}
properties = append(properties, "size="+strconv.Itoa(int(c.FileInfo.Size)))
}
return fmt.Sprintf("Content(%s)", strings.Join(properties, ","))
}
// PrepareForPackager performs the following steps to prepare the contents for
// the provided packager:
//
// - It filters out content that is irrelevant for the specified packager
// - It expands globs (if enabled) and file trees
// - It adds implicit directories (parent directories of files)
// - It adds ownership and other file information if not specified directly
// - It applies the given umask if the file does not have a specific mode
// - It normalizes content source paths to be unix style paths
// - It normalizes content destination paths to be absolute paths with a trailing
// slash if the entry is a directory
//
// If no packager is specified, only the files that are relevant for any
// packager are considered.
func PrepareForPackager(
rawContents Contents,
umask fs.FileMode,
packager string,
disableGlobbing bool,
mtime time.Time,
) (Contents, error) {
contentMap := make(map[string]*Content)
for _, content := range rawContents {
if !isRelevantForPackager(packager, content) {
continue
}
switch content.Type {
case TypeDir:
// implicit directories at the same destination can just be overwritten
presentContent, destinationOccupied := contentMap[NormalizeAbsoluteDirPath(content.Destination)]
if destinationOccupied && presentContent.Type != TypeImplicitDir {
return nil, contentCollisionError(content, presentContent)
}
err := addParents(contentMap, content.Destination, mtime)
if err != nil {
return nil, err
}
cc := content.WithFileInfoDefaults(umask, mtime)
cc.Source = ToNixPath(cc.Source)
cc.Destination = NormalizeAbsoluteDirPath(cc.Destination)
contentMap[cc.Destination] = cc
case TypeImplicitDir:
// if there's an implicit directory, the contents probably already
// have been expanded so we can just ignore it, it will be created
// by another content element again anyway
case TypeRPMGhost, TypeSymlink, TypeRPMDoc, TypeRPMLicence, TypeRPMLicense, TypeRPMReadme, TypeDebChangelog:
presentContent, destinationOccupied := contentMap[NormalizeAbsoluteFilePath(content.Destination)]
if destinationOccupied {
return nil, contentCollisionError(content, presentContent)
}
err := addParents(contentMap, content.Destination, mtime)
if err != nil {
return nil, err
}
cc := content.WithFileInfoDefaults(umask, mtime)
cc.Source = ToNixPath(cc.Source)
cc.Destination = NormalizeAbsoluteFilePath(cc.Destination)
contentMap[cc.Destination] = cc
case TypeTree:
err := addTree(contentMap, content, umask, mtime)
if err != nil {
return nil, fmt.Errorf("add tree: %w", err)
}
case TypeConfig, TypeConfigNoReplace, TypeFile, "":
globbed, err := glob.Glob(
filepath.ToSlash(content.Source),
filepath.ToSlash(content.Destination),
disableGlobbing,
)
if err != nil {
return nil, err
}
if err := addGlobbedFiles(contentMap, globbed, content, umask, mtime); err != nil {
return nil, fmt.Errorf("add globbed files from %q: %w", content.Source, err)
}
default:
return nil, fmt.Errorf("invalid content type: %s", content.Type)
}
}
res := make(Contents, 0, len(contentMap))
for _, content := range contentMap {
res = append(res, content)
}
sort.Sort(res)
return res, nil
}
func isRelevantForPackager(packager string, content *Content) bool {
if packager == "" {
return true
}
if content.Packager != "" && content.Packager != packager {
return false
}
if packager != "rpm" &&
(content.Type == TypeRPMDoc || content.Type == TypeRPMLicence ||
content.Type == TypeRPMLicense || content.Type == TypeRPMReadme ||
content.Type == TypeRPMGhost) {
return false
}
if packager != "deb" && content.Type == TypeDebChangelog {
return false
}
return true
}
func addParents(contentMap map[string]*Content, path string, mtime time.Time) error {
for _, parent := range sortedParents(path) {
parent = NormalizeAbsoluteDirPath(parent)
// check for content collision and just overwrite previously created
// implicit directories
c, ok := contentMap[parent]
if ok {
// either we already created this directory as an explicit directory
// or as an implicit directory of another file
if c.Type == TypeDir || c.Type == TypeImplicitDir {
continue
}
return contentCollisionError(&Content{
Type: "parent directory for " + path,
Destination: parent,
}, c)
}
contentMap[parent] = &Content{
Destination: parent,
Type: TypeImplicitDir,
FileInfo: &ContentFileInfo{
Owner: "root",
Group: "root",
Mode: 0o755,
MTime: mtime,
},
}
}
return nil
}
func sortedParents(dst string) []string {
paths := []string{}
base := strings.Trim(dst, "/")
for {
base = filepath.Dir(base)
if base == "." {
break
}
paths = append(paths, ToNixPath(base))
}
// reverse in place
for i := len(paths)/2 - 1; i >= 0; i-- {
oppositeIndex := len(paths) - 1 - i
paths[i], paths[oppositeIndex] = paths[oppositeIndex], paths[i]
}
return paths
}
func addGlobbedFiles(
all map[string]*Content,
globbed map[string]string,
origFile *Content,
umask fs.FileMode,
mtime time.Time,
) error {
for src, dst := range globbed {
dst = NormalizeAbsoluteFilePath(dst)
presentContent, destinationOccupied := all[dst]
if destinationOccupied {
c := *origFile
c.Destination = dst
return contentCollisionError(&c, presentContent)
}
if err := addParents(all, dst, mtime); err != nil {
return err
}
// if the file has a FileInfo, we need to copy it but recalculate its size
newFileInfo := origFile.FileInfo
if newFileInfo != nil {
newFileInfoVal := *newFileInfo
newFileInfoVal.Size = 0
newFileInfo = &newFileInfoVal
}
newFile := (&Content{
Destination: NormalizeAbsoluteFilePath(dst),
Source: ToNixPath(src),
Type: origFile.Type,
FileInfo: newFileInfo,
Packager: origFile.Packager,
}).WithFileInfoDefaults(umask, mtime)
if dst, err := os.Readlink(src); err == nil {
newFile.Source = dst
newFile.Type = TypeSymlink
}
all[dst] = newFile
}
return nil
}
func addTree(
all map[string]*Content,
tree *Content,
umask os.FileMode,
mtime time.Time,
) error {
if tree.Destination != "/" && tree.Destination != "" {
presentContent, destinationOccupied := all[NormalizeAbsoluteDirPath(tree.Destination)]
if destinationOccupied && presentContent.Type != TypeImplicitDir {
return contentCollisionError(tree, presentContent)
}
}
err := addParents(all, tree.Destination, mtime)
if err != nil {
return err
}
return filepath.WalkDir(tree.Source, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(tree.Source, path)
if err != nil {
return err
}
destination := filepath.Join(tree.Destination, relPath)
c := &Content{
FileInfo: &ContentFileInfo{},
}
if tree.FileInfo != nil && !ownedByFilesystem(tree.Destination) {
c.FileInfo.Owner = tree.FileInfo.Owner
c.FileInfo.Group = tree.FileInfo.Group
}
switch {
case d.IsDir():
info, err := d.Info()
if err != nil {
return fmt.Errorf("get directory information: %w", err)
}
c.Type = TypeDir
c.Destination = NormalizeAbsoluteDirPath(destination)
c.FileInfo.Mode = info.Mode() &^ umask
c.FileInfo.MTime = info.ModTime()
case d.Type()&os.ModeSymlink != 0:
linkDestination, err := os.Readlink(path)
if err != nil {
return err
}
c.Type = TypeSymlink
c.Source = filepath.ToSlash(strings.TrimPrefix(linkDestination, filepath.VolumeName(linkDestination)))
c.Destination = NormalizeAbsoluteFilePath(destination)
default:
c.Type = TypeFile
c.Source = path
c.Destination = NormalizeAbsoluteFilePath(destination)
c.FileInfo.Mode = d.Type() &^ umask
}
all[c.Destination] = c.WithFileInfoDefaults(umask, mtime)
return nil
})
}
var ErrContentCollision = fmt.Errorf("content collision")
func contentCollisionError(new *Content, present *Content) error {
var presentSource string
if present.Source != "" {
presentSource = " with source " + present.Source
}
return fmt.Errorf("adding %s at destination %s: "+
"%s%s is already present at this destination: %w",
new.Type, new.Destination, present.Type, presentSource, ErrContentCollision,
)
}
// ToNixPath converts the given path to a nix-style path.
//
// Windows-style path separators are considered escape
// characters by some libraries, which can cause issues.
func ToNixPath(path string) string {
return filepath.ToSlash(filepath.Clean(path))
}
// As relative path converts a path to an explicitly relative path starting with
// a dot (e.g. it converts /foo -> ./foo and foo -> ./foo).
func AsExplicitRelativePath(path string) string {
return "./" + AsRelativePath(path)
}
// AsRelativePath converts a path to a relative path without a "./" prefix. This
// function leaves trailing slashes to indicate that the path refers to a
// directory, and converts the path to Unix path.
func AsRelativePath(path string) string {
cleanedPath := strings.TrimLeft(ToNixPath(path), "/")
if len(cleanedPath) > 1 && strings.HasSuffix(path, "/") {
return cleanedPath + "/"
}
return cleanedPath
}
// NormalizeAbsoluteFilePath returns an absolute cleaned path separated by
// slashes.
func NormalizeAbsoluteFilePath(src string) string {
return ToNixPath(filepath.Join("/", src))
}
// normalizeFirPath is linke NormalizeAbsoluteFilePath with a trailing slash.
func NormalizeAbsoluteDirPath(path string) string {
return NormalizeAbsoluteFilePath(strings.TrimRight(path, "/")) + "/"
}