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 package formats it is handled exactly like TypeConfig. TypeConfigNoReplace = "config|noreplace" // TypeConfigMissingOK is like TypeConfig with an added missingok directive // that is respected by RPM-based distributions. // For all other package formats it is handled exactly like TypeConfig. TypeConfigMissingOK = "config|missingok" // 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 c.Type == TypeDir || c.Type == TypeImplicitDir } // Sys to part of the os.FileInfo interface func (c *Content) Sys() any { 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, TypeConfigMissingOK, 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() if ownedByFilesystem(c.Destination) { c.Type = TypeImplicitDir } 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 } if tree.FileInfo != nil && tree.FileInfo.Mode != 0 && c.Type != TypeSymlink { c.FileInfo.Mode = tree.FileInfo.Mode } all[c.Destination] = c.WithFileInfoDefaults(umask, mtime) return nil }) } var ErrContentCollision = fmt.Errorf("content collision") func contentCollisionError(newc *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", newc.Type, newc.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, "/")) + "/" }