Repository: goreleaser/nfpm
Pull request #390 (2021-11-03 21:31:25)

feat: Directory content type with custom attributes
This PR introduces the content type `dir` which can be used to add empty directories or directories with custom attributes such as owner, group or permissions. It also makes `EmptyFolders` obsolete.

Another important aspect is that it can be used to specified directories "owned" by the package when packaging RPMs as only those directories are removed when the package is uninstalled. This use case is also now well documented.

codecov[bot]: # [Codecov](https://codecov.io/gh/goreleaser/nfpm/pull/390?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) Report
> Merging [#390](https://codecov.io/gh/goreleaser/nfpm/pull/390?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) (09b60db) into [master](https://codecov.io/gh/goreleaser/nfpm/commit/984d00a7ed52af8d8729782e32c91b61ebd71e3d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) (984d00a) will **increase** coverage by `0.49%`.
> The diff coverage is `71.32%`.

[![Impacted file tree graph](https://codecov.io/gh/goreleaser/nfpm/pull/390/graphs/tree.svg?width=650&height=150&src=pr&token=9QKGIxn7wg&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser)](https://codecov.io/gh/goreleaser/nfpm/pull/390?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser)

```diff
@@            Coverage Diff             @@
##           master     #390      +/-   ##
==========================================
+ Coverage   64.66%   65.15%   +0.49%     
==========================================
  Files          14       14              
  Lines        1834     1857      +23     
==========================================
+ Hits         1186     1210      +24     
+ Misses        512      508       -4     
- Partials      136      139       +3     
```


| [Impacted Files](https://codecov.io/gh/goreleaser/nfpm/pull/390?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) | Coverage Δ | |
|---|---|---|
| [nfpm.go](https://codecov.io/gh/goreleaser/nfpm/pull/390/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-bmZwbS5nbw==) | `80.52% <7.14%> (-5.84%)` | :arrow_down: |
| [files/files.go](https://codecov.io/gh/goreleaser/nfpm/pull/390/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-ZmlsZXMvZmlsZXMuZ28=) | `89.14% <66.66%> (-6.51%)` | :arrow_down: |
| [rpm/rpm.go](https://codecov.io/gh/goreleaser/nfpm/pull/390/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-cnBtL3JwbS5nbw==) | `67.91% <78.00%> (-2.06%)` | :arrow_down: |
| [deb/deb.go](https://codecov.io/gh/goreleaser/nfpm/pull/390/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-ZGViL2RlYi5nbw==) | `69.03% <82.14%> (+2.99%)` | :arrow_up: |
| [apk/apk.go](https://codecov.io/gh/goreleaser/nfpm/pull/390/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-YXBrL2Fway5nbw==) | `72.38% <83.33%> (+3.68%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/goreleaser/nfpm/pull/390?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/goreleaser/nfpm/pull/390?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser). Last update [984d00a...09b60db](https://codecov.io/gh/goreleaser/nfpm/pull/390?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser).


Patch:
diff --git a/apk/apk.go b/apk/apk.go
index b466df2..2e20398 100644
--- a/apk/apk.go
+++ b/apk/apk.go
@@ -397,54 +397,65 @@ func createBuilderData(info *nfpm.Info, sizep *int64) func(tw *tar.Writer) error
 	created := map[string]bool{}
 
 	return func(tw *tar.Writer) error {
-		// handle empty folders
-		if err := createEmptyFoldersInsideTarGz(info, tw, created); err != nil {
-			return err
-		}
-
-		// handle Files
 		return createFilesInsideTarGz(info, tw, created, sizep)
 	}
 }
 
 func createFilesInsideTarGz(info *nfpm.Info, tw *tar.Writer, created map[string]bool, sizep *int64) (err error) {
+	// create explicit directories first
+	for _, file := range info.Contents {
+		// at this point, we don't care about other types yet
+		if file.Type != "dir" {
+			continue
+		}
+
+		// only consider contents for this packager
+		if file.Packager != "" && file.Packager != packagerName {
+			continue
+		}
+
+		err = tw.WriteHeader(&tar.Header{
+			Name:     files.ToNixPath(strings.Trim(file.Destination, "/") + "/"),
+			Mode:     int64(file.FileInfo.Mode),
+			Typeflag: tar.TypeDir,
+			Format:   tar.FormatGNU,
+			Uname:    file.FileInfo.Owner,
+			Gname:    file.FileInfo.Group,
+			ModTime:  file.FileInfo.MTime,
+		})
+		if err != nil {
+			return err
+		}
+
+		created[strings.TrimPrefix(file.Destination, "/")] = true
+	}
+
 	for _, file := range info.Contents {
+		// only consider contents for this packager
 		if file.Packager != "" && file.Packager != packagerName {
 			continue
 		}
+
+		// create implicit directory structure below the current content
 		if err = createTree(tw, file.Destination, created); err != nil {
 			return err
 		}
+
 		switch file.Type {
 		case "ghost":
 			// skip ghost files in apk
 			continue
+		case "dir":
+			// already handled above
+			continue
 		case "symlink":
 			err = createSymlinkInsideTarGz(file, tw)
-		case "doc":
-			// nolint:gocritic
-			// ignoring `emptyFallthrough: remove empty case containing only fallthrough to default case`
-			fallthrough
-		case "licence", "license":
-			// nolint:gocritic
-			// ignoring `emptyFallthrough: remove empty case containing only fallthrough to default case`
-			fallthrough
-		case "readme":
-			// nolint:gocritic
-			// ignoring `emptyFallthrough: remove empty case containing only fallthrough to default case`
-			fallthrough
-		case "config", "config|noreplace":
-			// nolint:gocritic
-			// ignoring `emptyFallthrough: remove empty case containing only fallthrough to default case`
-			fallthrough
 		default:
 			err = copyToTarAndDigest(file, tw, sizep)
 		}
 		if err != nil {
 			return err
 		}
-		created[file.Source] = true
-		created[file.Destination[1:]] = true
 	}
 
 	return nil
@@ -483,18 +494,6 @@ func copyToTarAndDigest(file *files.Content, tw *tar.Writer, sizep *int64) error
 	return nil
 }
 
-func createEmptyFoldersInsideTarGz(info *nfpm.Info, out *tar.Writer, created map[string]bool) error {
-	for _, folder := range info.EmptyFolders {
-		// this .nope is actually not created, because createTree ignore the
-		// last part of the path, assuming it is a file.
-		// TODO: should probably refactor this
-		if err := createTree(out, files.ToNixPath(filepath.Join(folder, ".nope")), created); err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
 // this is needed because the data.tar.gz file should have the empty folders
 // as well, so we walk through the dst and create all subfolders.
 func createTree(tarw *tar.Writer, dst string, created map[string]bool) error {
@@ -518,8 +517,8 @@ func createTree(tarw *tar.Writer, dst string, created map[string]bool) error {
 }
 
 func pathsToCreate(dst string) []string {
-	var paths []string
-	base := dst[1:]
+	paths := []string{}
+	base := strings.Trim(dst, "/")
 	for {
 		base = filepath.Dir(base)
 		if base == "." {
@@ -529,7 +528,7 @@ func pathsToCreate(dst string) []string {
 	}
 	// we don't really need to create those things in order apparently, but,
 	// it looks really weird if we don't.
-	var result []string
+	result := []string{}
 	for i := len(paths) - 1; i >= 0; i-- {
 		result = append(result, paths[i])
 	}
diff --git a/deb/deb.go b/deb/deb.go
index c486a7e..ede009c 100644
--- a/deb/deb.go
+++ b/deb/deb.go
@@ -10,7 +10,6 @@ import (
 	"fmt"
 	"io"
 	"io/ioutil"
-	"log"
 	"os"
 	"path/filepath"
 	"strings"
@@ -171,7 +170,7 @@ func (*Deb) SetPackagerDefaults(info *nfpm.Info) {
 	// if in the long run we should be more strict about this and error when
 	// not set?
 	if info.Maintainer == "" {
-		log.Println("DEPRECATION WARNING: Leaving the 'maintainer' field unset will not be allowed in a future version")
+		fmt.Fprintf(os.Stderr, "DEPRECATION WARNING: Leaving the 'maintainer' field unset will not be allowed in a future version")
 		info.Maintainer = "Unset Maintainer <unset@localhost>"
 	}
 }
@@ -243,9 +242,6 @@ func fillDataTar(info *nfpm.Info, w io.Writer) (md5sums []byte, instSize int64,
 	defer out.Close() // nolint: errcheck
 
 	created := map[string]bool{}
-	if err = createEmptyFoldersInsideDataTar(info, out, created); err != nil {
-		return nil, 0, err
-	}
 
 	md5buf, instSize, err := createFilesInsideDataTar(info, out, created)
 	if err != nil {
@@ -271,10 +267,41 @@ func createSymlinkInsideTar(file *files.Content, out *tar.Writer) error {
 
 func createFilesInsideDataTar(info *nfpm.Info, tw *tar.Writer,
 	created map[string]bool) (md5buf bytes.Buffer, instSize int64, err error) {
+	// create explicit directories first
 	for _, file := range info.Contents {
+		// at this point, we don't care about other types yet
+		if file.Type != "dir" {
+			continue
+		}
+
+		// only consider contents for this packager
 		if file.Packager != "" && file.Packager != packagerName {
 			continue
 		}
+
+		err = tw.WriteHeader(&tar.Header{
+			Name:     files.ToNixPath(strings.Trim(file.Destination, "/") + "/"),
+			Mode:     int64(file.FileInfo.Mode),
+			Typeflag: tar.TypeDir,
+			Format:   tar.FormatGNU,
+			Uname:    file.FileInfo.Owner,
+			Gname:    file.FileInfo.Group,
+			ModTime:  file.FileInfo.MTime,
+		})
+		if err != nil {
+			return md5buf, 0, err
+		}
+
+		created[strings.TrimPrefix(file.Destination, "/")] = true
+	}
+
+	// create files and implicit directories
+	for _, file := range info.Contents {
+		// only consider contents for this packager
+		if file.Packager != "" && file.Packager != packagerName {
+			continue
+		}
+		// create implicit directory structure below the current content
 		if err = createTree(tw, file.Destination, created); err != nil {
 			return md5buf, 0, err
 		}
@@ -284,24 +311,11 @@ func createFilesInsideDataTar(info *nfpm.Info, tw *tar.Writer,
 		case "ghost":
 			// skip ghost files in apk
 			continue
+		case "dir":
+			// already handled above
+			continue
 		case "symlink":
 			err = createSymlinkInsideTar(file, tw)
-		case "doc":
-			// nolint:gocritic
-			// ignoring `emptyFallthrough: remove empty case containing only fallthrough to default case`
-			fallthrough
-		case "licence", "license":
-			// nolint:gocritic
-			// ignoring `emptyFallthrough: remove empty case containing only fallthrough to default case`
-			fallthrough
-		case "readme":
-			// nolint:gocritic
-			// ignoring `emptyFallthrough: remove empty case containing only fallthrough to default case`
-			fallthrough
-		case "config", "config|noreplace":
-			// nolint:gocritic
-			// ignoring `emptyFallthrough: remove empty case containing only fallthrough to default case`
-			fallthrough
 		default:
 			size, err = copyToTarAndDigest(file, tw, &md5buf)
 		}
@@ -323,18 +337,6 @@ func createFilesInsideDataTar(info *nfpm.Info, tw *tar.Writer,
 	return md5buf, instSize, nil
 }
 
-func createEmptyFoldersInsideDataTar(info *nfpm.Info, out *tar.Writer, created map[string]bool) error {
-	for _, folder := range info.EmptyFolders {
-		// this .nope is actually not created, because createTree ignore the
-		// last part of the path, assuming it is a file.
-		// TODO: should probably refactor this
-		if err := createTree(out, files.ToNixPath(filepath.Join(folder, ".nope")), created); err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
 func copyToTarAndDigest(file *files.Content, tw *tar.Writer, md5w io.Writer) (int64, error) {
 	tarFile, err := os.OpenFile(file.Source, os.O_RDONLY, 0o600) //nolint:gosec
 	if err != nil {
@@ -345,7 +347,6 @@ func copyToTarAndDigest(file *files.Content, tw *tar.Writer, md5w io.Writer) (in
 
 	header, err := tar.FileInfoHeader(file, file.Source)
 	if err != nil {
-		log.Print(err)
 		return 0, err
 	}
 
@@ -602,7 +603,7 @@ func createTree(tarw *tar.Writer, dst string, created map[string]bool) error {
 
 func pathsToCreate(dst string) []string {
 	paths := []string{}
-	base := strings.TrimPrefix(dst, "/")
+	base := strings.Trim(dst, "/")
 	for {
 		base = filepath.Dir(base)
 		if base == "." {
diff --git a/files/files.go b/files/files.go
index 5640ae4..e837a4c 100644
--- a/files/files.go
+++ b/files/files.go
@@ -5,6 +5,7 @@ import (
 	"os"
 	"path/filepath"
 	"sort"
+	"strings"
 	"time"
 
 	"github.com/goreleaser/fileglob"
@@ -42,13 +43,26 @@ func (c Contents) Swap(i, j int) {
 
 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 len(a.Type) < len(b.Type)
+		return a.Type < b.Type
 	}
-	if a.Source != b.Source {
-		return a.Source < b.Source
+
+	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 a.Destination < b.Destination
+
+	return false
 }
 
 func (c *Content) WithFileInfoDefaults() *Content {
@@ -68,16 +82,29 @@ func (c *Content) WithFileInfoDefaults() *Content {
 	if cc.FileInfo.Group == "" {
 		cc.FileInfo.Group = "root"
 	}
-	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()
+	if cc.Type == "dir" && cc.FileInfo.Mode == 0 {
+		cc.FileInfo.Mode = 0o755
+	}
+
+	// determine if we still need info
+	fileInfoAlreadyComplete := (!cc.FileInfo.MTime.IsZero() &&
+		cc.FileInfo.Mode != 0 &&
+		(cc.FileInfo.Size != 0 || cc.Type == "dir"))
+
+	// 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()
+			}
+			cc.FileInfo.Size = info.Size()
 		}
-		cc.FileInfo.Size = info.Size()
 	}
+
 	if cc.FileInfo.MTime.IsZero() {
 		cc.FileInfo.MTime = time.Now().UTC()
 	}
@@ -125,8 +152,9 @@ func ExpandContentGlobs(contents Contents, disableGlobbing bool) (files Contents
 		var globbed map[string]string
 
 		switch f.Type {
-		case "ghost", "symlink":
-			// Ghost and symlink files need to be in the list, but dont glob them because they do not really exist
+		case "ghost", "symlink", "dir":
+			// Ghost, symlinks and dirs need to be in the list, but dont glob
+			// them because they do not really exist
 			files = append(files, f.WithFileInfoDefaults())
 		default:
 			globbed, err = glob.Glob(f.Source, f.Destination, options...)
diff --git a/nfpm.go b/nfpm.go
index 1a7cf45..be5df6d 100644
--- a/nfpm.go
+++ b/nfpm.go
@@ -374,7 +374,29 @@ func Validate(info *Info) (err error) {
 		return err
 	}
 
+	if len(info.EmptyFolders) > 0 {
+		fmt.Fprintf(os.Stderr, "DEPRECATION WARNING: 'empty_folders' is deprecated and "+
+			"will be removed in a future version, create content with type 'dir' and "+
+			"directoy name as 'dst' instead")
+
+		for _, emptyFolder := range info.EmptyFolders {
+			if contents.ContainsDestination(emptyFolder) {
+				return fmt.Errorf("empty folder already exists in contents: %s", emptyFolder)
+			}
+
+			contents = append(contents, &files.Content{
+				Destination: emptyFolder,
+				Type:        "dir",
+			})
+		}
+	}
+
+	// The deprecated EmptyFolders are already converted to contents, so we
+	// remove it such that Validate can be called more than once.
+	info.EmptyFolders = nil
+
 	info.Contents = contents
+
 	return nil
 }
 
diff --git a/rpm/rpm.go b/rpm/rpm.go
index 5c38231..ef90615 100644
--- a/rpm/rpm.go
+++ b/rpm/rpm.go
@@ -29,6 +29,8 @@ const (
 
 	// Symbolic link
 	tagLink = 0o120000
+	// Directory
+	tagDirectory = 0o40000
 
 	changelogNotesTemplate = `
 {{- range .Changes }}{{$note := splitList "\n" .Note}}
@@ -113,7 +115,6 @@ func (*RPM) Package(info *nfpm.Info, w io.Writer) (err error) {
 		rpm.SetPGPSigner(sign.PGPSignerWithKeyID(info.RPM.Signature.KeyFile, info.RPM.Signature.KeyPassphrase, info.RPM.Signature.KeyID))
 	}
 
-	addEmptyDirsRPM(info, rpm)
 	if err = createFilesInsideRPM(info, rpm); err != nil {
 		return err
 	}
@@ -323,89 +324,83 @@ func addScriptFiles(info *nfpm.Info, rpm *rpmpack.RPM) error {
 	return nil
 }
 
-func addEmptyDirsRPM(info *nfpm.Info, rpm *rpmpack.RPM) {
-	for _, dir := range info.EmptyFolders {
-		rpm.AddFile(
-			rpmpack.RPMFile{
-				Name:  dir,
-				Mode:  uint(0o40755),
-				MTime: uint32(time.Now().Unix()),
-				Owner: "root",
-				Group: "root",
-			},
-		)
-	}
-}
-
 func createFilesInsideRPM(info *nfpm.Info, rpm *rpmpack.RPM) (err error) {
-	var symlinks files.Contents
-	for _, file := range info.Contents {
-		if file.Packager != "" && file.Packager != packagerName {
+	for _, content := range info.Contents {
+		if content.Packager != "" && content.Packager != packagerName {
 			continue
 		}
 
-		var rpmFileType rpmpack.FileType
-		switch file.Type {
+		var file *rpmpack.RPMFile
+
+		switch content.Type {
 		case "config":
-			rpmFileType = rpmpack.ConfigFile
+			file, err = asRPMFile(content, rpmpack.ConfigFile)
 		case "config|noreplace":
-			rpmFileType = rpmpack.ConfigFile | rpmpack.NoReplaceFile
+			file, err = asRPMFile(content, rpmpack.ConfigFile|rpmpack.NoReplaceFile)
 		case "ghost":
-			rpmFileType = rpmpack.GhostFile
-			if file.FileInfo.Mode == 0 {
-				file.FileInfo.Mode = os.FileMode(0o644)
+			if content.FileInfo.Mode == 0 {
+				content.FileInfo.Mode = os.FileMode(0o644)
 			}
+
+			file, err = asRPMFile(content, rpmpack.GhostFile)
 		case "doc":
-			rpmFileType = rpmpack.DocFile
+			file, err = asRPMFile(content, rpmpack.DocFile)
 		case "licence", "license":
-			rpmFileType = rpmpack.LicenceFile
+			file, err = asRPMFile(content, rpmpack.LicenceFile)
 		case "readme":
-			rpmFileType = rpmpack.ReadmeFile
+			file, err = asRPMFile(content, rpmpack.ReadmeFile)
 		case "symlink":
-			symlinks = append(symlinks, file)
-			continue
+			file = asRPMSymlink(content)
+		case "dir":
+			file, err = asRPMDirectory(content)
 		default:
-			rpmFileType = rpmpack.GenericFile
+			file, err = asRPMFile(content, rpmpack.GenericFile)
 		}
 
-		if err = copyToRPM(rpm, file, rpmFileType); err != nil {
+		if err != nil {
 			return err
 		}
-	}
 
-	addSymlinksInsideRPM(symlinks, rpm)
+		rpm.AddFile(*file)
+	}
 
 	return nil
 }
 
-func addSymlinksInsideRPM(symlinks files.Contents, rpm *rpmpack.RPM) {
-	for _, file := range symlinks {
-		rpm.AddFile(rpmpack.RPMFile{
-			Name:  file.Destination,
-			Body:  []byte(file.Source),
-			Mode:  uint(tagLink),
-			MTime: uint32(file.FileInfo.MTime.Unix()),
-			Owner: file.FileInfo.Owner,
-			Group: file.FileInfo.Group,
-		})
+func asRPMDirectory(content *files.Content) (*rpmpack.RPMFile, error) {
+	return &rpmpack.RPMFile{
+		Name:  content.Destination,
+		Mode:  uint(content.Mode()) | tagDirectory,
+		MTime: uint32(time.Now().Unix()),
+		Owner: content.FileInfo.Owner,
+		Group: content.FileInfo.Group,
+	}, nil
+}
+
+func asRPMSymlink(content *files.Content) *rpmpack.RPMFile {
+	return &rpmpack.RPMFile{
+		Name:  content.Destination,
+		Body:  []byte(content.Source),
+		Mode:  uint(tagLink),
+		MTime: uint32(content.FileInfo.MTime.Unix()),
+		Owner: content.FileInfo.Owner,
+		Group: content.FileInfo.Group,
 	}
 }
 
-func copyToRPM(rpm *rpmpack.RPM, file *files.Content, fileType rpmpack.FileType) (err error) {
-	data, err := ioutil.ReadFile(file.Source)
-	if err != nil && file.Type != "ghost" {
-		return err
+func asRPMFile(content *files.Content, fileType rpmpack.FileType) (*rpmpack.RPMFile, error) {
+	data, err := ioutil.ReadFile(content.Source)
+	if err != nil && content.Type != "ghost" {
+		return nil, err
 	}
 
-	rpm.AddFile(rpmpack.RPMFile{
-		Name:  file.Destination,
+	return &rpmpack.RPMFile{
+		Name:  content.Destination,
 		Body:  data,
-		Mode:  uint(file.FileInfo.Mode),
-		MTime: uint32(file.FileInfo.MTime.Unix()),
-		Owner: file.FileInfo.Owner,
-		Group: file.FileInfo.Group,
+		Mode:  uint(content.FileInfo.Mode),
+		MTime: uint32(content.FileInfo.MTime.Unix()),
+		Owner: content.FileInfo.Owner,
+		Group: content.FileInfo.Group,
 		Type:  fileType,
-	})
-
-	return nil
+	}, nil
 }
diff --git a/www/docs/configuration.md b/www/docs/configuration.md
index af0eff6..734ba22 100644
--- a/www/docs/configuration.md
+++ b/www/docs/configuration.md
@@ -56,8 +56,7 @@ priority: extra
 
 # Maintainer.
 # Defaults to empty on rpm and apk
-# Leaving this field empty on 'deb' packages is deprecated, and will be removed
-# in a future release
+# Leaving the 'maintainer' field unset will not be allowed in a future version
 maintainer: Carlos Alexandro Becker <root@carlosbecker.com>
 
 # Description.
@@ -165,7 +164,9 @@ contents:
     packager: apk
 
   # Sometimes it is important to be able to set the mtime, mode, owner, or group for a file
-  # that differs from what is on the local build system at build time.
+  # that differs from what is on the local build system at build time. The owner (if different
+  # than 'root') has to be always specified manually in 'file_info' as it will not be copied
+  # from the 'src' file.
   - src: path/to/foo
     dst: /usr/local/foo
     file_info:
@@ -175,6 +176,18 @@ contents:
       owner: notRoot
       group: notRoot
 
+  # Using the type 'dir', empty directories can be created. When building RPMs, however, this
+  # type has another important purpose: Claiming ownership of that folder. This is important
+  # because when upgrading or removing an RPM package, only the directories for which it has
+  # claimed ownership are removed. However, you should not claim ownership of a folder that
+  # is created by the distro or a dependency of your package.
+  # A directory in the build environment can optionally be provided in the 'src' field in
+  # order copy mtime and mode from that directory without having to specifiy it manually.
+  - dst: /some/dir
+    type: dir
+    file_info:
+      mode: 0700
+
 # Empty folders your package may need created. (overridable)
 empty_folders:
   - /var/log/foo


Test patch:
diff --git a/acceptance_test.go b/acceptance_test.go
index d083da3..11aa8d1 100644
--- a/acceptance_test.go
+++ b/acceptance_test.go
@@ -189,6 +189,7 @@ func TestRPMSpecific(t *testing.T) {
 	format := "rpm"
 	testNames := []string{
 		"release",
+		"directories",
 	}
 	for _, name := range testNames {
 		for _, arch := range formatArchs[format] {
diff --git a/apk/apk_test.go b/apk/apk_test.go
index c159973..639ad2c 100644
--- a/apk/apk_test.go
+++ b/apk/apk_test.go
@@ -11,6 +11,7 @@ import (
 	"fmt"
 	"io"
 	"io/ioutil"
+	"path"
 	"path/filepath"
 	"testing"
 
@@ -74,10 +75,14 @@ func exampleInfo() *nfpm.Info {
 					Destination: "/etc/fake/fake.conf",
 					Type:        "config",
 				},
-			},
-			EmptyFolders: []string{
-				"/var/log/whatever",
-				"/usr/share/whatever",
+				{
+					Destination: "/var/log/whatever",
+					Type:        "dir",
+				},
+				{
+					Destination: "/usr/share/whatever",
+					Type:        "dir",
+				},
 			},
 		},
 	})
@@ -95,7 +100,7 @@ func TestCreateBuilderData(t *testing.T) {
 
 	require.NoError(t, builderData(tw))
 
-	require.Equal(t, 11784, buf.Len())
+	require.Equal(t, 12288, buf.Len())
 }
 
 func TestCombineToApk(t *testing.T) {
@@ -115,7 +120,7 @@ func TestPathsToCreate(t *testing.T) {
 	for pathToTest, parts := range map[string][]string{
 		"/usr/share/doc/whatever/foo.md": {"usr", "usr/share", "usr/share/doc", "usr/share/doc/whatever"},
 		"/var/moises":                    {"var"},
-		"/":                              []string(nil),
+		"/":                              {},
 	} {
 		parts := parts
 		pathToTest := pathToTest
@@ -462,6 +467,129 @@ func TestPackageSymlinks(t *testing.T) {
 	require.NoError(t, Default.Package(info, ioutil.Discard))
 }
 
+func TestDirectories(t *testing.T) {
+	info := exampleInfo()
+	info.Contents = []*files.Content{
+		{
+			Source:      "../testdata/whatever.conf",
+			Destination: "/etc/foo/file",
+		},
+		{
+			Source:      "../testdata/whatever.conf",
+			Destination: "/etc/bar/file",
+		},
+		{
+			Destination: "/etc/bar",
+			Type:        "dir",
+			FileInfo: &files.ContentFileInfo{
+				Owner: "test",
+				Mode:  0o700,
+			},
+		},
+		{
+			Destination: "/etc/baz",
+			Type:        "dir",
+		},
+	}
+
+	require.NoError(t, info.Validate())
+
+	var buf bytes.Buffer
+	size := int64(0)
+	err := createFilesInsideTarGz(info, tar.NewWriter(&buf), make(map[string]bool), &size)
+	require.NoError(t, err)
+
+	// for apks all implicit or explicit directories are created in the tarball
+	h := extractFileHeaderFromTar(t, buf.Bytes(), "/etc")
+	require.NoError(t, err)
+	require.Equal(t, h.Typeflag, byte(tar.TypeDir))
+	h = extractFileHeaderFromTar(t, buf.Bytes(), "/etc/foo")
+	require.NoError(t, err)
+	require.Equal(t, h.Typeflag, byte(tar.TypeDir))
+	h = extractFileHeaderFromTar(t, buf.Bytes(), "/etc/bar")
+	require.NoError(t, err)
+	require.Equal(t, h.Typeflag, byte(tar.TypeDir))
+	require.Equal(t, h.Mode, int64(0o700))
+	require.Equal(t, h.Uname, "test")
+	h = extractFileHeaderFromTar(t, buf.Bytes(), "/etc/baz")
+	require.NoError(t, err)
+	require.Equal(t, h.Typeflag, byte(tar.TypeDir))
+}
+
+func TestNoDuplicateContents(t *testing.T) {
+	info := exampleInfo()
+	info.Contents = []*files.Content{
+		{
+			Source:      "../testdata/whatever.conf",
+			Destination: "/etc/foo/file",
+		},
+		{
+			Source:      "../testdata/whatever.conf",
+			Destination: "/etc/bar/file",
+		},
+		{
+			Destination: "/etc/bar",
+			Type:        "dir",
+			FileInfo: &files.ContentFileInfo{
+				Owner: "test",
+				Mode:  0o700,
+			},
+		},
+		{
+			Destination: "/etc/baz",
+			Type:        "dir",
+		},
+	}
+
+	require.NoError(t, info.Validate())
+
+	var buf bytes.Buffer
+	size := int64(0)
+	err := createFilesInsideTarGz(info, tar.NewWriter(&buf), make(map[string]bool), &size)
+	require.NoError(t, err)
+
+	exists := map[string]bool{}
+
+	tr := tar.NewReader(bytes.NewReader(buf.Bytes()))
+	for {
+		hdr, err := tr.Next()
+		if errors.Is(err, io.EOF) {
+			break // End of archive
+		}
+		require.NoError(t, err)
+
+		_, ok := exists[hdr.Name]
+		if ok {
+			t.Fatalf("%s exists more than once in tarball", hdr.Name)
+		}
+
+		exists[hdr.Name] = true
+	}
+}
+
+func extractFileHeaderFromTar(tb testing.TB, tarFile []byte, filename string) *tar.Header {
+	tb.Helper()
+
+	tr := tar.NewReader(bytes.NewReader(tarFile))
+	for {
+		hdr, err := tr.Next()
+		if errors.Is(err, io.EOF) {
+			break // End of archive
+		}
+		require.NoError(tb, err)
+
+		if path.Join("/", hdr.Name) != path.Join("/", filename) { // nolint:gosec
+			continue
+		}
+
+		return hdr
+	}
+
+	tb.Fatalf("file %q does not exist in tar", filename)
+
+	return nil
+}
+
 func TestArches(t *testing.T) {
 	for k := range archToAlpine {
 		t.Run(k, func(t *testing.T) {
diff --git a/deb/deb_test.go b/deb/deb_test.go
index 6e49902..189f849 100644
--- a/deb/deb_test.go
+++ b/deb/deb_test.go
@@ -74,10 +74,14 @@ func exampleInfo() *nfpm.Info {
 					Destination: "/etc/fake/fake.conf",
 					Type:        "config",
 				},
-			},
-			EmptyFolders: []string{
-				"/var/log/whatever",
-				"/usr/share/whatever",
+				{
+					Destination: "/var/log/whatever",
+					Type:        "dir",
+				},
+				{
+					Destination: "/usr/share/whatever",
+					Type:        "dir",
+				},
 			},
 		},
 	})
@@ -748,7 +752,7 @@ func TestMD5Sums(t *testing.T) {
 
 	nFiles := 1
 	for _, f := range info.Contents {
-		if f.Packager == "" || f.Packager == "deb" {
+		if (f.Packager == "" || f.Packager == "deb") && f.Type != "dir" {
 			nFiles++
 		}
 	}
@@ -779,6 +783,100 @@ func TestMD5Sums(t *testing.T) {
 	}
 }
 
+func TestDirectories(t *testing.T) {
+	info := exampleInfo()
+	info.Contents = []*files.Content{
+		{
+			Source:      "../testdata/whatever.conf",
+			Destination: "/etc/foo/file",
+		},
+		{
+			Source:      "../testdata/whatever.conf",
+			Destination: "/etc/bar/file",
+		},
+		{
+			Destination: "/etc/bar",
+			Type:        "dir",
+			FileInfo: &files.ContentFileInfo{
+				Owner: "test",
+				Mode:  0o700,
+			},
+		},
+		{
+			Destination: "/etc/baz",
+			Type:        "dir",
+		},
+	}
+
+	require.NoError(t, info.Validate())
+
+	deflatedDataTarball, _, _, dataTarballName, err := createDataTarball(info)
+	require.NoError(t, err)
+	dataTarball := inflate(t, dataTarballName, deflatedDataTarball)
+
+	// for debs all implicit or explicit directories are created in the tarball
+	h := extractFileHeaderFromTar(t, dataTarball, "/etc")
+	require.NoError(t, err)
+	require.Equal(t, h.Typeflag, byte(tar.TypeDir))
+	h = extractFileHeaderFromTar(t, dataTarball, "/etc/foo")
+	require.NoError(t, err)
+	require.Equal(t, h.Typeflag, byte(tar.TypeDir))
+	h = extractFileHeaderFromTar(t, dataTarball, "/etc/bar")
+	require.NoError(t, err)
+	require.Equal(t, h.Typeflag, byte(tar.TypeDir))
+	require.Equal(t, h.Mode, int64(0o700))
+	require.Equal(t, h.Uname, "test")
+	h = extractFileHeaderFromTar(t, dataTarball, "/etc/baz")
+	require.NoError(t, err)
+	require.Equal(t, h.Typeflag, byte(tar.TypeDir))
+}
+
+func TestNoDuplicateContents(t *testing.T) {
+	info := exampleInfo()
+	info.Contents = []*files.Content{
+		{
+			Source:      "../testdata/whatever.conf",
+			Destination: "/etc/foo/file",
+		},
+		{
+			Source:      "../testdata/whatever.conf",
+			Destination: "/etc/foo/file2",
+		},
+		{
+			Destination: "/etc/foo",
+			Type:        "dir",
+		},
+		{
+			Destination: "/etc/baz",
+			Type:        "dir",
+		},
+	}
+
+	require.NoError(t, info.Validate())
+
+	deflatedDataTarball, _, _, dataTarballName, err := createDataTarball(info)
+	require.NoError(t, err)
+	dataTarball := inflate(t, dataTarballName, deflatedDataTarball)
+
+	exists := map[string]bool{}
+
+	tr := tar.NewReader(bytes.NewReader(dataTarball))
+	for {
+		hdr, err := tr.Next()
+		if errors.Is(err, io.EOF) {
+			break // End of archive
+		}
+		require.NoError(t, err)
+
+		_, ok := exists[hdr.Name]
+		if ok {
+			t.Fatalf("%s exists more than once in tarball", hdr.Name)
+		}
+
+		exists[hdr.Name] = true
+	}
+}
+
 func testRelativePathPrefixInTar(tb testing.TB, tarFile []byte) {
 	tb.Helper()
 
diff --git a/rpm/rpm_test.go b/rpm/rpm_test.go
index 1bf9f6e..20d2372 100644
--- a/rpm/rpm_test.go
+++ b/rpm/rpm_test.go
@@ -63,10 +63,14 @@ func exampleInfo() *nfpm.Info {
 					Destination: "/etc/fake/fake.conf",
 					Type:        "config",
 				},
-			},
-			EmptyFolders: []string{
-				"/var/log/whatever",
-				"/usr/share/whatever",
+				{
+					Destination: "/var/log/whatever",
+					Type:        "dir",
+				},
+				{
+					Destination: "/usr/share/whatever",
+					Type:        "dir",
+				},
 			},
 			Scripts: nfpm.Scripts{
 				PreInstall:  "../testdata/scripts/preinstall.sh",
@@ -752,6 +756,51 @@ func TestDisableGlobbing(t *testing.T) {
 	require.Equal(t, expectedContent, actualContent)
 }
 
+func TestDirectories(t *testing.T) {
+	info := exampleInfo()
+	info.Contents = []*files.Content{
+		{
+			Source:      "../testdata/whatever.conf",
+			Destination: "/etc/foo/file",
+		},
+		{
+			Source:      "../testdata/whatever.conf",
+			Destination: "/etc/bar/file",
+		},
+		{
+			Destination: "/etc/bar",
+			Type:        "dir",
+		},
+		{
+			Destination: "/etc/baz",
+			Type:        "dir",
+			FileInfo: &files.ContentFileInfo{
+				Owner: "test",
+				Mode:  0o700,
+			},
+		},
+	}
+
+	var rpmFileBuffer bytes.Buffer
+	err := Default.Package(info, &rpmFileBuffer)
+	require.NoError(t, err)
+
+	// the directory /etc/foo should not be implicitly created as that
+	// implies ownership of /etc/foo which should always be implicit
+	_, err = extractFileHeaderFromRpm(rpmFileBuffer.Bytes(), "/etc/foo")
+	require.Equal(t, err, os.ErrNotExist)
+
+	// claiming explicit ownership of /etc/bar which already contains a file
+	h, err := extractFileHeaderFromRpm(rpmFileBuffer.Bytes(), "/etc/bar")
+	require.NoError(t, err)
+	require.NotEqual(t, h.Mode()&int(tagDirectory), 0)
+
+	// creating an empty folder (which also implies ownership)
+	h, err = extractFileHeaderFromRpm(rpmFileBuffer.Bytes(), "/etc/baz")
+	require.NoError(t, err)
+	require.Equal(t, h.Mode(), int(tagDirectory|0o700))
+}
+
 func extractFileFromRpm(rpm []byte, filename string) ([]byte, error) {
 	rpmFile, err := rpmutils.ReadRpm(bytes.NewReader(rpm))
 	if err != nil {
diff --git a/testdata/acceptance/rpm.directories.yaml b/testdata/acceptance/rpm.directories.yaml
new file mode 100644
index 0000000..6a237c9
--- /dev/null
+++ b/testdata/acceptance/rpm.directories.yaml
@@ -0,0 +1,24 @@
+name: "foo"
+arch: "${BUILD_ARCH}"
+platform: "linux"
+version: "v1.2.3"
+maintainer: "Foo Bar"
+release: "4"
+description: |
+  Foo bar
+    Multiple lines
+vendor: "foobar"
+homepage: "https://foobar.org"
+license: "MIT"
+contents:
+  - src: ./testdata/fake
+    dst: /etc/foo/file
+  - src: ./testdata/fake
+    dst: /etc/bar/file
+  - dst: /etc/bar
+    type: dir
+  - dst: /etc/baz
+    type: dir
+    file_info:
+      mode: 0700
+      group: test
diff --git a/testdata/acceptance/rpm.dockerfile b/testdata/acceptance/rpm.dockerfile
index 91c0e9f..90649fe 100644
--- a/testdata/acceptance/rpm.dockerfile
+++ b/testdata/acceptance/rpm.dockerfile
@@ -196,4 +196,20 @@ RUN test -f /etc/foo/whatever.conf
 RUN echo wat >> /etc/foo/whatever.conf
 RUN rpm -e foo
 RUN test -f /etc/foo/whatever.conf.rpmsave
-RUN test ! -f /usr/local/bin/fake
\ No newline at end of file
+RUN test ! -f /usr/local/bin/fake
+
+# ---- directories test ----
+FROM test_base AS directories
+RUN groupadd test
+RUN rpm -ivh /tmp/foo.rpm
+RUN test -f /etc/foo/file
+RUN test -f /etc/bar/file
+RUN test -d /etc/bar
+RUN test -d /etc/baz
+RUN stat -L -c "%a %U %G" /etc/baz | grep "700 root test"
+RUN rpm -e foo
+RUN test ! -f /etc/foo/file
+RUN test ! -f /etc/bar/file
+RUN test -d /etc/foo
+RUN test ! -d /etc/bar
+RUN test ! -d /etc/baz