diff --git a/deb/deb.go b/deb/deb.go index 96fc9cdd..704830a7 100644 --- a/deb/deb.go +++ b/deb/deb.go @@ -773,7 +773,11 @@ type controlData struct { } func writeControl(w io.Writer, data controlData) error { - tmpl := template.New("control") + return writeTemplate("control", controlTemplate, w, data) +} + +func writeTemplate(name, t string, w io.Writer, data interface{}) error { + tmpl := template.New(name) tmpl.Funcs(template.FuncMap{ "join": func(strs []string) string { return strings.Trim(strings.Join(strs, ", "), " ") @@ -806,5 +810,5 @@ func writeControl(w io.Writer, data controlData) error { return result }, }) - return template.Must(tmpl.Parse(controlTemplate)).Execute(w, data) + return template.Must(tmpl.Parse(t)).Execute(w, data) } diff --git a/deb/deb_meta.go b/deb/deb_meta.go new file mode 100644 index 00000000..5eaa8d41 --- /dev/null +++ b/deb/deb_meta.go @@ -0,0 +1,161 @@ +package deb + +import ( + "bytes" + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "fmt" + "io" + "path/filepath" + "strings" + "time" + + "github.com/goreleaser/nfpm/v2" + "github.com/goreleaser/nfpm/v2/internal/sign" +) + +const debChangesTemplate = ` +{{- /* Mandatory fields */ -}} +Format: 1.8 +Date: {{.Date}} +Source: {{.Info.Name}} +Binary: {{.Info.Deb.Metadata.Binary}} +Architecture: {{ if ne .Info.Platform "linux"}}{{ .Info.Platform }}-{{ end }}{{.Info.Arch}} +Version: {{ if .Info.Epoch}}{{ .Info.Epoch }}:{{ end }}{{.Info.Version}} + {{- if .Info.Prerelease}}~{{ .Info.Prerelease }}{{- end }} + {{- if .Info.VersionMetadata}}+{{ .Info.VersionMetadata }}{{- end }} + {{- if .Info.Release}}-{{ .Info.Release }}{{- end }} +Distribution: {{.Info.Deb.Metadata.Distribution}} +{{- if .Info.Deb.Metadata.Urgency }} +Urgency: {{.Info.Deb.Metadata.Urgency}} +{{- end }} +Maintainer: {{.Info.Maintainer}} +{{- if .Info.Deb.Metadata.ChangedBy }} +Changed-By: {{.Info.Deb.Metadata.ChangedBy}} +{{- end }} +Description: {{ multiline .Info.Description }} +{{- range $key, $value := .Info.Deb.Metadata.Fields }} +{{- if $value }} +{{$key}}: {{$value}} +{{- end }} +{{- end }} +Changes: +{{range .Changes}} {{.}}{{end}} +Checksums-Sha256: +{{range .Files}} {{ .Sha256Sum }} {{.Size}} {{.Name}}{{end}} +Checksums-Sha1: +{{range .Files}} {{ .Sha1Sum }} {{.Size}} {{.Name}}{{end}} +Files: +{{range .Files}} {{ .Md5Sum }} {{.Size}} {{.Section}} {{.Priority}} {{.Name}}{{end}} + +` + +type changesData struct { + Date string + Info *nfpm.Info + Changes []string + Files []changesFileData +} + +type changesFileData struct { + Name string + Size int + Section string + Priority string + Md5Sum string + Sha1Sum string + Sha256Sum string +} + +var now = time.Now + +func (d *Deb) ConventionalMetadataFileName(info *nfpm.Info) string { + target := info.Target + + if target == "" { + target = d.ConventionalFileName(info) + } + + return strings.Replace(target, ".deb", ".changes", 1) +} + +func (d *Deb) PackageMetadata(metaInfo *nfpm.MetaInfo, w io.Writer) error { + data, err := createChanges(metaInfo) + if err != nil { + return err + } + + if metaInfo.Info.Deb.Signature.KeyFile == "" { + _, err = w.Write(data.Bytes()) + return err + } + + signedData, err := signChanges(data, metaInfo.Info) + if err != nil { + return err + } + + _, err = w.Write(signedData) + return err +} + +func createChanges(info *nfpm.MetaInfo) (*bytes.Buffer, error) { + data, err := prepareChangesData(info) + if err != nil { + return nil, err + } + + buf := new(bytes.Buffer) + + if err := writeTemplate("changes", debChangesTemplate, buf, data); err != nil { + return nil, err + } + + return buf, nil +} + +func prepareChangesData(meta *nfpm.MetaInfo) (*changesData, error) { + info := meta.Info + + _, err := meta.Package.Seek(0, io.SeekStart) + if err != nil { + return nil, err + } + + buf := new(bytes.Buffer) + + _, err = buf.ReadFrom(meta.Package) + if err != nil { + return nil, err + } + + return &changesData{ + Date: now().Format(time.RFC1123Z), + Info: info, + Changes: []string{ + fmt.Sprintf("%s (%s) %s; urgency=%s\n * Package created with nFPM", + info.Name, info.Version, info.Deb.Metadata.Distribution, info.Deb.Metadata.Urgency), + }, + Files: []changesFileData{ + { + Name: filepath.Base(info.Target), + Size: buf.Len(), + Section: info.Section, + Priority: info.Priority, + Md5Sum: fmt.Sprintf("%x", md5.Sum(buf.Bytes())), + Sha1Sum: fmt.Sprintf("%x", sha1.Sum(buf.Bytes())), + Sha256Sum: fmt.Sprintf("%x", sha256.Sum256(buf.Bytes())), + }, + }, + }, nil +} + +func signChanges(data *bytes.Buffer, info *nfpm.Info) ([]byte, error) { + signConfig := info.Deb.Signature + signature, err := sign.PGPClearSignWithKeyID(data, signConfig.KeyFile, signConfig.KeyPassphrase, signConfig.KeyID) + if err != nil { + return nil, &nfpm.ErrSigningFailure{Err: err} + } + return signature, nil +} diff --git a/deb/deb_meta_test.go b/deb/deb_meta_test.go new file mode 100644 index 00000000..369811e0 --- /dev/null +++ b/deb/deb_meta_test.go @@ -0,0 +1,110 @@ +package deb + +import ( + "bytes" + "os" + "testing" + "time" + + "github.com/goreleaser/nfpm/v2" + "github.com/goreleaser/nfpm/v2/internal/sign" + "github.com/stretchr/testify/require" +) + +func fakeTime() time.Time { + t, _ := time.Parse(time.RFC1123Z, "Mon, 30 Jan 2023 08:36:31 +0300") + return t +} + +func TestMetaFilename(t *testing.T) { + info := exampleInfo() + + require.Equal(t, "foo_1.0.0_amd64.changes", Default.ConventionalMetadataFileName(info)) +} + +func TestMetaFilenameTarget(t *testing.T) { + info := exampleInfo() + info.Target = "bar_1.0.0_amd64.deb" + + require.Equal(t, "bar_1.0.0_amd64.changes", Default.ConventionalMetadataFileName(info)) +} + +func TestMetaFields(t *testing.T) { + now = fakeTime + + var w bytes.Buffer + pkg := bytes.NewReader([]byte{1, 2, 3}) + info := nfpm.WithDefaults(&nfpm.Info{ + Name: "foo", + Description: "Foo does things", + Priority: "extra", + Maintainer: "Carlos A Becker ", + Version: "v1.0.0", + Section: "default", + Homepage: "http://carlosbecker.com", + EnableMetadata: true, + Overridables: nfpm.Overridables{ + Deb: nfpm.Deb{ + Metadata: nfpm.DebMetadata{ + Binary: "foo", + Distribution: "unstable", + Urgency: "medium", + ChangedBy: "Carlos A Becker ", + Fields: map[string]string{ + "Bugs": "https://github.com/goreleaser/nfpm/issues", + "Empty": "", + }, + }, + }, + }, + }) + info.Target = Default.ConventionalFileName(info) + + require.NoError(t, Default.PackageMetadata(&nfpm.MetaInfo{ + Info: info, + Package: pkg, + }, &w)) + golden := "testdata/changes.golden" + if *update { + require.NoError(t, os.WriteFile(golden, w.Bytes(), 0o600)) + } + bts, err := os.ReadFile(golden) //nolint:gosec + require.NoError(t, err) + require.Equal(t, string(bts), w.String()) +} + +func TestMetaSignature(t *testing.T) { + info := exampleInfo() + info.Target = Default.ConventionalFileName(info) + info.Deb.Signature.KeyFile = "../internal/sign/testdata/privkey.asc" + info.Deb.Signature.KeyPassphrase = "hunter2" + + var w bytes.Buffer + pkg := bytes.NewReader([]byte{1, 2, 3}) + + require.NoError(t, Default.PackageMetadata(&nfpm.MetaInfo{ + Info: info, + Package: pkg, + }, &w)) + + _, err := sign.PGPReadMessage(w.Bytes(), "../internal/sign/testdata/pubkey.asc") + require.NoError(t, err) +} + +func TestMetaSignatureError(t *testing.T) { + now = fakeTime + info := exampleInfo() + info.Target = Default.ConventionalFileName(info) + info.Deb.Signature.KeyFile = "/does/not/exist" + + var w bytes.Buffer + pkg := bytes.NewReader([]byte{1, 2, 3}) + err := Default.PackageMetadata(&nfpm.MetaInfo{ + Info: info, + Package: pkg, + }, &w) + require.Error(t, err) + + var expectedError *nfpm.ErrSigningFailure + require.ErrorAs(t, err, &expectedError) +} diff --git a/deb/testdata/changes.golden b/deb/testdata/changes.golden new file mode 100644 index 00000000..7a3b4928 --- /dev/null +++ b/deb/testdata/changes.golden @@ -0,0 +1,22 @@ +Format: 1.8 +Date: Mon, 30 Jan 2023 08:36:31 +0300 +Source: foo +Binary: foo +Architecture: amd64 +Version: 1.0.0 +Distribution: unstable +Urgency: medium +Maintainer: Carlos A Becker +Changed-By: Carlos A Becker +Description: Foo does things +Bugs: https://github.com/goreleaser/nfpm/issues +Changes: + foo (1.0.0) unstable; urgency=medium + * Package created with nFPM +Checksums-Sha256: + 039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81 3 foo_1.0.0_amd64.deb +Checksums-Sha1: + 7037807198c22a7d2b0807371d763779a84fdfcf 3 foo_1.0.0_amd64.deb +Files: + 5289df737df57326fcdd22597afb1fac 3 default extra foo_1.0.0_amd64.deb + diff --git a/internal/cmd/package.go b/internal/cmd/package.go index 6187156b..9196ee11 100644 --- a/internal/cmd/package.go +++ b/internal/cmd/package.go @@ -3,6 +3,7 @@ package cmd import ( "errors" "fmt" + "io" "os" "path" "path/filepath" @@ -113,5 +114,34 @@ func doPackage(configPath, target, packager string) error { } fmt.Printf("created package: %s\n", target) + + meta, supports := pkg.(nfpm.PackagerWithMetadata) + if !supports || !info.EnableMetadata { + return nil + } + + return doPackageMeta(meta, f, info) +} + +func doPackageMeta(pkgMeta nfpm.PackagerWithMetadata, p io.ReadSeeker, info *nfpm.Info) error { + target := pkgMeta.ConventionalMetadataFileName(info) + + f, err := os.Create(target) + if err != nil { + return err + } + defer f.Close() + + metaInfo := &nfpm.MetaInfo{ + Info: info, + Package: p, + } + + if err := pkgMeta.PackageMetadata(metaInfo, f); err != nil { + _ = os.Remove(target) + return err + } + + fmt.Printf("created package metadata: %s\n", target) return f.Close() } diff --git a/nfpm.go b/nfpm.go index 180c2e16..4884e611 100644 --- a/nfpm.go +++ b/nfpm.go @@ -128,6 +128,11 @@ type PackagerWithExtension interface { ConventionalExtension() string } +type PackagerWithMetadata interface { + PackageMetadata(info *MetaInfo, w io.Writer) error + ConventionalMetadataFileName(info *Info) string +} + // Config contains the top level configuration for packages. type Config struct { Info `yaml:",inline" json:",inline"` @@ -304,6 +309,13 @@ type Info struct { DisableGlobbing bool `yaml:"disable_globbing,omitempty" json:"disable_globbing,omitempty" jsonschema:"title=whether to disable file globbing,default=false"` MTime time.Time `yaml:"mtime,omitempty" json:"mtime,omitempty" jsonschema:"title=time to set into the files generated by nFPM"` Target string `yaml:"-" json:"-"` + EnableMetadata bool `yaml:"enable_metadata,omitempty" json:"enable_metadata,omitempty" jsonschema:"title=enable_metadata"` +} + +// MetaInfo is a wrapper around information about single package and generated package data +type MetaInfo struct { + Info *Info + Package io.ReadSeeker } func (i *Info) Validate() error { @@ -430,14 +442,26 @@ type APKScripts struct { // Deb is custom configs that are only available on deb packages. type Deb struct { - Arch string `yaml:"arch,omitempty" json:"arch,omitempty" jsonschema:"title=architecture in deb nomenclature"` - Scripts DebScripts `yaml:"scripts,omitempty" json:"scripts,omitempty" jsonschema:"title=scripts"` - Triggers DebTriggers `yaml:"triggers,omitempty" json:"triggers,omitempty" jsonschema:"title=triggers"` - Breaks []string `yaml:"breaks,omitempty" json:"breaks,omitempty" jsonschema:"title=breaks"` - Signature DebSignature `yaml:"signature,omitempty" json:"signature,omitempty" jsonschema:"title=signature"` - Compression string `yaml:"compression,omitempty" json:"compression,omitempty" jsonschema:"title=compression algorithm to be used,enum=gzip,enum=xz,enum=none,default=gzip"` - Fields map[string]string `yaml:"fields,omitempty" json:"fields,omitempty" jsonschema:"title=fields"` - Predepends []string `yaml:"predepends,omitempty" json:"predepends,omitempty" jsonschema:"title=predepends directive,example=nfpm"` + Arch string `yaml:"arch,omitempty" json:"arch,omitempty" jsonschema:"title=architecture in deb nomenclature"` + Scripts DebScripts `yaml:"scripts,omitempty" json:"scripts,omitempty" jsonschema:"title=scripts"` + Triggers DebTriggers `yaml:"triggers,omitempty" json:"triggers,omitempty" jsonschema:"title=triggers"` + Breaks []string `yaml:"breaks,omitempty" json:"breaks,omitempty" jsonschema:"title=breaks"` + Signature DebSignature `yaml:"signature,omitempty" json:"signature,omitempty" jsonschema:"title=signature"` + Compression string `yaml:"compression,omitempty" json:"compression,omitempty" jsonschema:"title=compression algorithm to be used,enum=gzip,enum=xz,enum=none,default=gzip"` + Fields map[string]string `yaml:"fields,omitempty" json:"fields,omitempty" jsonschema:"title=fields"` + Predepends []string `yaml:"predepends,omitempty" json:"predepends,omitempty" jsonschema:"title=predepends directive,example=nfpm"` + Distribution string `yaml:"distribution" json:"distribution" jsonschema:"title=distribution"` + Urgency string `yaml:"urgency" json:"urgency" jsonschema:"title=urgency"` + Metadata DebMetadata `yaml:"metadata,omitempty" json:"metadata,omitempty" jsonschema:"title=metadata"` +} + +// DebMetadata is custom configs for generating .changes file that are only available on deb packages. +type DebMetadata struct { + Binary string `yaml:"binary" json:"binary" jsonschema:"title=binary"` + Distribution string `yaml:"distribution" json:"distribution" jsonschema:"title=distribution"` + Urgency string `yaml:"urgency" json:"urgency" jsonschema:"title=urgency"` + ChangedBy string `yaml:"changed_by" json:"changed_by" jsonschema:"title=changed_by"` + Fields map[string]string `yaml:"fields,omitempty" json:"fields,omitempty" jsonschema:"title=fields"` } type DebSignature struct {