1
1
mirror of https://github.com/goreleaser/nfpm synced 2026-07-24 06:54:54 +02:00
Files
nfpm/rpm/spec.go
Carlos Alexandro Becker d20c0c9937 fix(rpm): escape % in generated SRPM spec so scriptlets survive rebuild (#1109)
User content (scriptlets, %description, summary, file paths and changelog)
was inlined verbatim into the generated .spec file. rpmbuild reinterprets %
when the SRPM is rebuilt: %% collapses to a single %, %{macro} expands, and
%(command) executes at build time. The common longest-suffix-strip idiom
${VAR%%.*} silently became ${VAR%.*} (shortest suffix) in install scriptlets,
with no warning.

Double every % in user-controlled spec text via escapeSpecText so rpmbuild
restores it literally on rebuild. Binary RPMs are unaffected -- their
scriptlets go straight into header tags with no spec parsing -- so this is
scoped to the SRPM path.

Add a unit test asserting the escaped output, and extend the srpm rebuild
acceptance test with a ${host%%.*} scriptlet that must survive the real
rpmbuild --rebuild.

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 14:48:34 -03:00

192 lines
6.1 KiB
Go

package rpm
import (
"fmt"
"io/fs"
"strings"
"github.com/goreleaser/nfpm/v2"
"github.com/goreleaser/nfpm/v2/files"
)
// generateSpec synthesizes a self-contained, rebuildable RPM spec from the
// package metadata. %install extracts the bundled source tarball into the
// buildroot, and %files re-declares every path with its attributes, so
// `rpmbuild --rebuild` reproduces the binary RPM.
func generateSpec(info *nfpm.Info, sourceName string) (string, error) {
summary := defaultTo(info.RPM.Summary, strings.Split(info.Description, "\n")[0])
summary = defaultTo(summary, info.Name)
description := defaultTo(info.Description, summary)
var b strings.Builder
// Ship prebuilt artifacts verbatim: no debuginfo extraction, no binary
// post-processing/stripping, and no auto-generated dependencies.
b.WriteString("%global debug_package %{nil}\n")
b.WriteString("%global __os_install_post %{nil}\n")
b.WriteString("%define _build_id_links none\n\n")
writeField := func(key, value string) {
if value != "" {
fmt.Fprintf(&b, "%s: %s\n", key, escapeSpecText(value))
}
}
writeField("Name", info.Name)
writeField("Version", formatVersion(info))
writeField("Release", defaultTo(info.Release, "1"))
if info.Epoch != "" {
writeField("Epoch", info.Epoch)
}
writeField("Summary", summary)
writeField("License", info.License)
writeField("URL", info.Homepage)
writeField("Group", info.RPM.Group)
writeField("Vendor", info.Vendor)
writeField("Packager", defaultTo(info.RPM.Packager, info.Maintainer))
writeField("BuildArch", info.Arch)
for _, prefix := range info.RPM.Prefixes {
writeField("Prefix", prefix)
}
// Dependencies are declared explicitly; do not let rpmbuild scan the prebuilt
// payload for additional ones.
b.WriteString("AutoReqProv: no\n")
for _, dep := range info.Depends {
writeField("Requires", dep)
}
for _, dep := range info.RPM.Requires.Post {
writeField("Requires(post)", dep)
}
for _, dep := range info.Provides {
writeField("Provides", dep)
}
for _, dep := range info.Conflicts {
writeField("Conflicts", dep)
}
for _, dep := range info.Replaces {
writeField("Obsoletes", dep)
}
for _, dep := range info.Recommends {
writeField("Recommends", dep)
}
for _, dep := range info.Suggests {
writeField("Suggests", dep)
}
fmt.Fprintf(&b, "Source0: %s\n", sourceName)
fmt.Fprintf(&b, "\n%%description\n%s\n", escapeSpecText(description))
b.WriteString("\n%prep\n")
b.WriteString("\n%build\n")
b.WriteString("\n%install\n")
b.WriteString("rm -rf %{buildroot}\n")
b.WriteString("mkdir -p %{buildroot}\n")
b.WriteString("tar -C %{buildroot} -xf %{SOURCE0}\n")
scripts, err := readScripts(info)
if err != nil {
return "", err
}
writeScriptSection(&b, "pre", scripts.preIn)
writeScriptSection(&b, "post", scripts.postIn)
writeScriptSection(&b, "preun", scripts.preUn)
writeScriptSection(&b, "postun", scripts.postUn)
writeScriptSection(&b, "pretrans", scripts.preTrans)
writeScriptSection(&b, "posttrans", scripts.postTrans)
writeScriptSection(&b, "verifyscript", scripts.verify)
b.WriteString("\n%files\n")
writeFilesSection(&b, info)
changelog, err := renderSpecChangelog(info)
if err != nil {
return "", err
}
b.WriteString(changelog)
return b.String(), nil
}
func writeScriptSection(b *strings.Builder, section, body string) {
if body == "" {
return
}
body = escapeSpecText(body)
fmt.Fprintf(b, "\n%%%s\n%s", section, body)
if !strings.HasSuffix(body, "\n") {
b.WriteString("\n")
}
}
func writeFilesSection(b *strings.Builder, info *nfpm.Info) {
for _, content := range info.Contents {
if content.Packager != "" && content.Packager != contentPackager {
continue
}
if line := specFileLine(content); line != "" {
b.WriteString(line)
b.WriteString("\n")
}
}
}
func specFileLine(content *files.Content) string {
dest := escapeSpecText(files.ToNixPath(content.Destination))
owner := defaultTo(content.FileInfo.Owner, "root")
group := defaultTo(content.FileInfo.Group, "root")
// %lang() is orthogonal to the file directive and prefixes the file-bearing
// entries (config/doc/license/readme/generic). It is meaningless for
// directories, symlinks and ghosts, which never carry a language tag.
langPrefix := ""
if content.FileInfo.Lang != "" {
langPrefix = fmt.Sprintf("%%lang(%s) ", content.FileInfo.Lang)
}
switch content.Type {
case files.TypeImplicitDir:
return ""
case files.TypeSymlink:
return fmt.Sprintf("%%attr(-, %s, %s) %q", owner, group, dest)
case files.TypeDir:
return fmt.Sprintf("%%dir %s", attrPath(content.Mode(), owner, group, dest))
case files.TypeConfig:
return langPrefix + "%config " + attrPath(content.FileInfo.Mode, owner, group, dest)
case files.TypeConfigNoReplace:
return langPrefix + "%config(noreplace) " + attrPath(content.FileInfo.Mode, owner, group, dest)
case files.TypeConfigMissingOK:
return langPrefix + "%config(missingok) " + attrPath(content.FileInfo.Mode, owner, group, dest)
case files.TypeRPMDoc:
return langPrefix + "%doc " + attrPath(content.FileInfo.Mode, owner, group, dest)
case files.TypeRPMLicence, files.TypeRPMLicense:
return langPrefix + "%license " + attrPath(content.FileInfo.Mode, owner, group, dest)
case files.TypeRPMReadme:
return langPrefix + "%doc " + attrPath(content.FileInfo.Mode, owner, group, dest)
case files.TypeRPMGhost:
mode := content.FileInfo.Mode
if mode == 0 {
mode = 0o644
}
return "%ghost " + attrPath(mode, owner, group, dest)
default:
return langPrefix + attrPath(content.FileInfo.Mode, owner, group, dest)
}
}
// attrPath renders an "%attr(mode, owner, group) path" entry for the %files
// section, normalizing the mode to its octal RPM representation.
func attrPath(mode fs.FileMode, owner, group, dest string) string {
return fmt.Sprintf("%%attr(%04o, %s, %s) %q", normalizeFileMode(mode), owner, group, dest)
}
// escapeSpecText doubles every percent sign so that rpmbuild treats the value
// as literal text when the generated SRPM is rebuilt, instead of expanding
// %macro / %(command) / %% sequences. Apply it to user-controlled content only,
// never to the spec directives nfpm itself emits.
func escapeSpecText(s string) string {
return strings.ReplaceAll(s, "%", "%%")
}