diff --git a/Taskfile.yml b/Taskfile.yml index b49dfc0a..27cd5db3 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -42,6 +42,7 @@ tasks: - go run ./cmd/nfpm/... pkg -f {{.SRC}} -p rpm -t ./dist/foo.rpm - go run ./cmd/nfpm/... pkg -f {{.SRC}} -p apk -t ./dist/foo.apk - go run ./cmd/nfpm/... pkg -f {{.SRC}} -p archlinux -t ./dist/foo.pkg.tar.zst + - go run ./cmd/nfpm/... pkg -f {{.SRC}} -p xbps -t ./dist/foo.xbps - go run ./cmd/nfpm/... pkg -f {{.MSIX_SRC}} -p msix -t ./dist/foo.msix acceptance:windows:install: diff --git a/acceptance_test.go b/acceptance_test.go index 0b21515c..0b4a4c51 100644 --- a/acceptance_test.go +++ b/acceptance_test.go @@ -19,6 +19,7 @@ import ( _ "github.com/goreleaser/nfpm/v2/ipk" _ "github.com/goreleaser/nfpm/v2/msix" _ "github.com/goreleaser/nfpm/v2/rpm" + _ "github.com/goreleaser/nfpm/v2/xbps" "github.com/stretchr/testify/require" ) @@ -346,6 +347,112 @@ func TestDebSign(t *testing.T) { } } +func TestXBPSSpecific(t *testing.T) { + t.Parallel() + format := "xbps" + arch := "amd64" + + for _, testCase := range []struct { + name string + conf string + pkgfile string + buildArgs []string + }{ + { + name: "lifecycle", + conf: "xbps.lifecycle.yaml", + pkgfile: "foo-1.2.3_1.x86_64.xbps", + buildArgs: []string{"scenario=lifecycle"}, + }, + { + name: "metadata", + conf: "xbps.metadata.yaml", + pkgfile: "foo-1.2.3_1.x86_64.xbps", + buildArgs: []string{"scenario=metadata"}, + }, + { + name: "noarch", + conf: "xbps.noarch.yaml", + pkgfile: "foo-1.2.3_1.noarch.xbps", + buildArgs: []string{"scenario=noarch"}, + }, + } { + testCase := testCase + t.Run(fmt.Sprintf("%s/%s/%s", format, arch, testCase.name), func(t *testing.T) { + t.Parallel() + generatedPackage := fmt.Sprintf("tmp/xbps_%s_%s.%s", testCase.name, arch, format) + buildArgs := append([]string{}, testCase.buildArgs...) + buildArgs = append(buildArgs, + fmt.Sprintf("pkgfile=%s", testCase.pkgfile), + fmt.Sprintf("oldpackage=%s", generatedPackage), + fmt.Sprintf("oldpkgfile=%s", testCase.pkgfile), + ) + accept(t, acceptParms{ + Name: fmt.Sprintf("xbps_%s_%s", testCase.name, arch), + Conf: testCase.conf, + Format: format, + Docker: dockerParams{ + File: "xbps.dockerfile", + Target: "scenario", + Arch: arch, + BuildArgs: buildArgs, + }, + }) + }) + } + + t.Run(fmt.Sprintf("%s/%s/upgrade", format, arch), func(t *testing.T) { + t.Parallel() + testArch := arch + oldpkg := fmt.Sprintf("tmp/xbps_upgrade_%s.v1.%s", testArch, format) + target := fmt.Sprintf("./testdata/acceptance/%s", oldpkg) + repoTmp := "./testdata/acceptance/tmp" + require.NoError(t, os.MkdirAll(repoTmp, 0o700)) + + config, err := nfpm.ParseFileWithEnvMapping("./testdata/acceptance/xbps.upgrade.v1.yaml", func(s string) string { + switch s { + case "BUILD_ARCH": + return testArch + default: + return os.Getenv(s) + } + }) + require.NoError(t, err) + + info, err := config.Get(format) + require.NoError(t, err) + require.NoError(t, nfpm.Validate(info)) + + pkg, err := nfpm.Get(format) + require.NoError(t, err) + + f, err := os.Create(target) + require.NoError(t, err) + info.Target = target + packageErr := pkg.Package(nfpm.WithDefaults(info), f) + closeErr := f.Close() + require.NoError(t, packageErr) + require.NoError(t, closeErr) + + accept(t, acceptParms{ + Name: fmt.Sprintf("xbps_upgrade_%s.v2", testArch), + Conf: "xbps.upgrade.v2.yaml", + Format: format, + Docker: dockerParams{ + File: "xbps.dockerfile", + Target: "scenario", + Arch: testArch, + BuildArgs: []string{ + "scenario=upgrade", + fmt.Sprintf("oldpackage=%s", oldpkg), + "oldpkgfile=foo-1.2.3_1.x86_64.xbps", + "pkgfile=foo-1.2.4_1.x86_64.xbps", + }, + }, + }) + }) +} + type acceptParms struct { Name string Conf string diff --git a/cmd/nfpm/main.go b/cmd/nfpm/main.go index 9410904e..273a5f1a 100644 --- a/cmd/nfpm/main.go +++ b/cmd/nfpm/main.go @@ -33,7 +33,7 @@ func main() { func buildVersion(version, commit, date, builtBy, treeState string) goversion.Info { return goversion.GetVersionInfo( - goversion.WithAppDetails("nfpm", "a simple and 0-dependencies apk, arch linux, deb, ipk, msix, and rpm packager written in Go", website), + goversion.WithAppDetails("nfpm", "a simple and 0-dependencies apk, arch linux, deb, ipk, msix, rpm, and xbps packager written in Go", website), goversion.WithASCIIName(asciiArt), func(i *goversion.Info) { if commit != "" { diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 63019fc2..43c648b8 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -12,6 +12,7 @@ import ( _ "github.com/goreleaser/nfpm/v2/ipk" // ipk packager _ "github.com/goreleaser/nfpm/v2/msix" // msix packager _ "github.com/goreleaser/nfpm/v2/rpm" // rpm packager + _ "github.com/goreleaser/nfpm/v2/xbps" // xbps packager "github.com/spf13/cobra" ) @@ -45,8 +46,8 @@ func newRootCmd(version goversion.Info, exit func(int)) *rootCmd { } cmd := &cobra.Command{ Use: "nfpm", - Short: "Packages apps on RPM, Deb, APK, Arch Linux, ipk, and MSIX formats based on a YAML configuration file", - Long: `nFPM is a simple and 0-dependencies apk, arch, deb, ipk, msix, and rpm packager written in Go.`, + Short: "Packages apps on RPM, Deb, APK, Arch Linux, ipk, MSIX, and XBPS formats based on a YAML configuration file", + Long: `nFPM is a simple and 0-dependencies apk, arch, deb, ipk, msix, rpm, and xbps packager written in Go.`, Version: version.String(), Args: cobra.NoArgs, ValidArgsFunction: cobra.NoFileCompletions, diff --git a/internal/sign/rsa.go b/internal/sign/rsa.go index 58afb9a1..5979c66e 100644 --- a/internal/sign/rsa.go +++ b/internal/sign/rsa.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "crypto/rsa" "crypto/sha1" // nolint:gosec + "crypto/sha256" "crypto/x509" "encoding/pem" "errors" @@ -14,10 +15,11 @@ import ( ) var ( - errNoPemBlock = errors.New("no PEM block found") - errDigestNotSH1 = errors.New("digest is not a SHA1 hash") - errNoPassphrase = errors.New("key is encrypted but no passphrase was provided") - errNoRSAKey = errors.New("key is not an RSA key") + errNoPemBlock = errors.New("no PEM block found") + errDigestNotSH1 = errors.New("digest is not a SHA1 hash") + errDigestNotSHA256 = errors.New("digest is not a SHA256 hash") + errNoPassphrase = errors.New("key is encrypted but no passphrase was provided") + errNoRSAKey = errors.New("key is not an RSA key") ) const ( @@ -28,15 +30,38 @@ const ( // RSASignSHA1Digest signs the provided SHA1 message digest. The key file // must be in the PEM format and can either be encrypted or not. func RSASignSHA1Digest(sha1Digest []byte, keyFile, passphrase string) ([]byte, error) { - if len(sha1Digest) != sha1.Size { - return nil, errDigestNotSH1 + return rsaSignDigest(sha1Digest, keyFile, passphrase, crypto.SHA1, sha1.Size, errDigestNotSH1) +} + +// RSASignSHA256Digest signs the provided SHA256 message digest. The key file +// must be in the PEM format and can either be encrypted or not. +func RSASignSHA256Digest(sha256Digest []byte, keyFile, passphrase string) ([]byte, error) { + return rsaSignDigest(sha256Digest, keyFile, passphrase, crypto.SHA256, sha256.Size, errDigestNotSHA256) +} + +func rsaSignDigest(digest []byte, keyFile, passphrase string, hash crypto.Hash, digestSize int, digestErr error) ([]byte, error) { + if len(digest) != digestSize { + return nil, digestErr } + priv, err := loadPrivateKey(keyFile, passphrase) + if err != nil { + return nil, err + } + + signature, err := priv.Sign(rand.Reader, digest, hash) + if err != nil { + return nil, fmt.Errorf("signing: %w", err) + } + + return signature, nil +} + +func loadPrivateKey(keyFile, passphrase string) (crypto.Signer, error) { keyFileContent, err := os.ReadFile(keyFile) if err != nil { return nil, fmt.Errorf("reading key file: %w", err) } - block, _ := pem.Decode(keyFileContent) if block == nil { return nil, errNoPemBlock @@ -47,45 +72,29 @@ func RSASignSHA1Digest(sha1Digest []byte, keyFile, passphrase string) ([]byte, e if passphrase == "" { return nil, errNoPassphrase } - - var decryptedBlockData []byte - - decryptedBlockData, err = x509.DecryptPEMBlock(block, []byte(passphrase)) //nolint:staticcheck + decryptedBlockData, err := x509.DecryptPEMBlock(block, []byte(passphrase)) //nolint:staticcheck if err != nil { return nil, fmt.Errorf("decrypt private key PEM block: %w", err) } - blockData = decryptedBlockData } - var priv crypto.Signer - switch block.Type { case PKCS1PrivkeyPreamble: - priv, err = x509.ParsePKCS1PrivateKey(blockData) - if err != nil { - return nil, fmt.Errorf("parse PKCS#1 private key: %w", err) - } + return x509.ParsePKCS1PrivateKey(blockData) case PKCS8PrivkeyPreamble: privAny, err := x509.ParsePKCS8PrivateKey(blockData) if err != nil { return nil, fmt.Errorf("parse PKCS#8 private key: %w", err) } - privTmp, ok := privAny.(crypto.Signer) + priv, ok := privAny.(*rsa.PrivateKey) if !ok { - return nil, fmt.Errorf("cannot sign with given private key") + return nil, errNoRSAKey } - priv = privTmp + return priv, nil default: return nil, fmt.Errorf(`key type "%v" is not supported`, block.Type) } - - signature, err := priv.Sign(rand.Reader, sha1Digest, crypto.SHA1) - if err != nil { - return nil, fmt.Errorf("signing: %w", err) - } - - return signature, nil } func rsaSign(message io.Reader, keyFile, passphrase string) ([]byte, error) { @@ -101,36 +110,53 @@ func rsaSign(message io.Reader, keyFile, passphrase string) ([]byte, error) { // RSAVerifySHA1Digest is exported for use in tests and verifies a signature over the // provided SHA1 hash of a message. The key file must be in the PEM format. func RSAVerifySHA1Digest(sha1Digest, signature []byte, publicKeyFile string) error { - if len(sha1Digest) != sha1.Size { - return errDigestNotSH1 + return rsaVerifyDigest(sha1Digest, signature, publicKeyFile, crypto.SHA1, sha1.Size, errDigestNotSH1) +} + +// RSAVerifySHA256Digest is exported for use in tests and verifies a signature over the +// provided SHA256 hash of a message. The key file must be in the PEM format. +func RSAVerifySHA256Digest(sha256Digest, signature []byte, publicKeyFile string) error { + return rsaVerifyDigest(sha256Digest, signature, publicKeyFile, crypto.SHA256, sha256.Size, errDigestNotSHA256) +} + +func rsaVerifyDigest(digest, signature []byte, publicKeyFile string, hash crypto.Hash, digestSize int, digestErr error) error { + if len(digest) != digestSize { + return digestErr } - keyFileContent, err := os.ReadFile(publicKeyFile) + rsaPub, err := loadRSAPublicKey(publicKeyFile) if err != nil { - return fmt.Errorf("reading key file: %w", err) + return err } + err = rsa.VerifyPKCS1v15(rsaPub, hash, digest, signature) + if err != nil { + return fmt.Errorf("verify PKCS1v15 signature: %w", err) + } + + return nil +} + +func loadRSAPublicKey(publicKeyFile string) (*rsa.PublicKey, error) { + keyFileContent, err := os.ReadFile(publicKeyFile) + if err != nil { + return nil, fmt.Errorf("reading key file: %w", err) + } block, _ := pem.Decode(keyFileContent) if block == nil { - return errNoPemBlock + return nil, errNoPemBlock } pub, err := x509.ParsePKIXPublicKey(block.Bytes) if err != nil { - return fmt.Errorf("parse PKIX public key: %w", err) + return nil, fmt.Errorf("parse PKIX public key: %w", err) } - rsaPub, ok := pub.(*rsa.PublicKey) if !ok { - return errNoRSAKey - } - - err = rsa.VerifyPKCS1v15(rsaPub, crypto.SHA1, sha1Digest, signature) - if err != nil { - return fmt.Errorf("verify PKCS1v15 signature: %w", err) + return nil, errNoRSAKey } - return nil + return rsaPub, nil } func rsaVerify(message io.Reader, signature []byte, publicKeyFile string) error { diff --git a/internal/sign/rsa_test.go b/internal/sign/rsa_test.go index a869c7d4..0b7616ef 100644 --- a/internal/sign/rsa_test.go +++ b/internal/sign/rsa_test.go @@ -2,7 +2,15 @@ package sign import ( "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "crypto/sha1" // nolint:gosec + "crypto/sha256" + "crypto/x509" + "encoding/pem" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/require" @@ -105,3 +113,102 @@ func TestRSAVerifyWrongSecretKeyFormat(t *testing.T) { _, err := rsaSign(bytes.NewReader(digest), "testdata/wrong_key_format.priv", "") require.Error(t, err) } + +func TestRSASignAndVerifySHA256Digest(t *testing.T) { + testData := []byte("test") + digest := sha256.Sum256(testData) + testCases := []struct { + name string + privKey string + pubKey string + passphrase string + }{ + {"unprotected pkcs1", "testdata/rsa_unprotected.priv", "testdata/rsa_unprotected.pub", ""}, + {"protected pkcs1", "testdata/rsa.priv", "testdata/rsa.pub", pass}, + {"unprotected pkcs8", "testdata/rsa_pkcs8.priv", "testdata/rsa_pkcs8.pub", ""}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + sig, err := RSASignSHA256Digest(digest[:], testCase.privKey, testCase.passphrase) + require.NoError(t, err) + require.NoError(t, RSAVerifySHA256Digest(digest[:], sig, testCase.pubKey)) + }) + } +} + +func TestInvalidSHA256Hash(t *testing.T) { + invalidDigest := []byte("test") + _, err := RSASignSHA256Digest(invalidDigest, "testdata/rsa.priv", "hunter2") + require.EqualError(t, err, "digest is not a SHA256 hash") + + err = RSAVerifySHA256Digest(invalidDigest, []byte{}, "testdata/rsa.pub") + require.EqualError(t, err, "digest is not a SHA256 hash") +} + +func TestRSAVerifySHA256WrongSignature(t *testing.T) { + digest := sha256.Sum256([]byte("test")) + err := RSAVerifySHA256Digest(digest[:], []byte{}, "testdata/rsa.pub") + require.EqualError(t, err, "verify PKCS1v15 signature: crypto/rsa: verification error") +} + +func TestLoadPrivateKeyFileNotFound(t *testing.T) { + _, err := loadPrivateKey("testdata/does_not_exist.priv", "") + require.ErrorContains(t, err, "reading key file") +} + +func TestLoadPrivateKeyNoPEMBlock(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "garbage.priv") + require.NoError(t, os.WriteFile(path, []byte("not a pem block"), 0o600)) + _, err := loadPrivateKey(path, "") + require.ErrorIs(t, err, errNoPemBlock) +} + +func TestLoadPrivateKeyPKCS8ParseError(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad_pkcs8.priv") + bad := pem.EncodeToMemory(&pem.Block{Type: PKCS8PrivkeyPreamble, Bytes: []byte("not-valid-der")}) + require.NoError(t, os.WriteFile(path, bad, 0o600)) + _, err := loadPrivateKey(path, "") + require.ErrorContains(t, err, "parse PKCS#8 private key") +} + +func TestLoadPrivateKeyRejectsNonRSAPKCS8(t *testing.T) { + // A PKCS#8 ECDSA key parses fine but is not RSA; since every signing and + // verification path is RSA-only, loadPrivateKey must reject it up front + // rather than emit a signature no RSA verifier can validate. + ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + der, err := x509.MarshalPKCS8PrivateKey(ecKey) + require.NoError(t, err) + dir := t.TempDir() + path := filepath.Join(dir, "ecdsa_pkcs8.priv") + pemBytes := pem.EncodeToMemory(&pem.Block{Type: PKCS8PrivkeyPreamble, Bytes: der}) + require.NoError(t, os.WriteFile(path, pemBytes, 0o600)) + + _, err = loadPrivateKey(path, "") + require.ErrorIs(t, err, errNoRSAKey) +} + +func TestLoadRSAPublicKeyFileNotFound(t *testing.T) { + _, err := loadRSAPublicKey("testdata/does_not_exist.pub") + require.ErrorContains(t, err, "reading key file") +} + +func TestLoadRSAPublicKeyNoPEMBlock(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "garbage.pub") + require.NoError(t, os.WriteFile(path, []byte("not a pem block"), 0o600)) + _, err := loadRSAPublicKey(path) + require.ErrorIs(t, err, errNoPemBlock) +} + +func TestLoadRSAPublicKeyParseError(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.pub") + bad := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: []byte("not-valid-der")}) + require.NoError(t, os.WriteFile(path, bad, 0o600)) + _, err := loadRSAPublicKey(path) + require.ErrorContains(t, err, "parse PKIX public key") +} diff --git a/nfpm.go b/nfpm.go index 25007b0f..04145233 100644 --- a/nfpm.go +++ b/nfpm.go @@ -283,6 +283,27 @@ func (c *Config) expandEnvVars() { } c.IPK.Predepends = c.expandEnvVarsStringSlice(c.IPK.Predepends) + // XBPS specific + c.XBPS.Arch = os.Expand(c.XBPS.Arch, c.envMappingFunc) + c.XBPS.ShortDesc = os.Expand(c.XBPS.ShortDesc, c.envMappingFunc) + c.XBPS.Tags = c.expandEnvVarsStringSlice(c.XBPS.Tags) + c.XBPS.Reverts = c.expandEnvVarsStringSlice(c.XBPS.Reverts) + for i := range c.XBPS.Alternatives { + c.XBPS.Alternatives[i].Group = os.Expand(c.XBPS.Alternatives[i].Group, c.envMappingFunc) + c.XBPS.Alternatives[i].LinkName = os.Expand(c.XBPS.Alternatives[i].LinkName, c.envMappingFunc) + c.XBPS.Alternatives[i].Target = os.Expand(c.XBPS.Alternatives[i].Target, c.envMappingFunc) + } + c.XBPS.Signature.KeyFile = os.Expand(c.XBPS.Signature.KeyFile, c.envMappingFunc) + c.XBPS.Signature.KeyPassphrase = generalPassphrase + xbpsPassphrase := os.Expand("$XBPS_PASSPHRASE", c.envMappingFunc) + if xbpsPassphrase != "" { + c.XBPS.Signature.KeyPassphrase = xbpsPassphrase + } + nfpmXBPSPassphrase := os.Expand("$NFPM_XBPS_PASSPHRASE", c.envMappingFunc) + if nfpmXBPSPassphrase != "" { + c.XBPS.Signature.KeyPassphrase = nfpmXBPSPassphrase + } + // MSIX specific c.MSIX.Signature.PFXFile = os.Expand(c.MSIX.Signature.PFXFile, c.envMappingFunc) c.MSIX.Publisher = os.Expand(c.MSIX.Publisher, c.envMappingFunc) @@ -371,6 +392,7 @@ type Overridables struct { APK APK `yaml:"apk,omitempty" json:"apk,omitempty" jsonschema:"title=apk-specific settings"` ArchLinux ArchLinux `yaml:"archlinux,omitempty" json:"archlinux,omitempty" jsonschema:"title=archlinux-specific settings"` IPK IPK `yaml:"ipk,omitempty" json:"ipk,omitempty" jsonschema:"title=ipk-specific settings"` + XBPS XBPS `yaml:"xbps,omitempty" json:"xbps,omitempty" jsonschema:"title=xbps-specific settings"` MSIX MSIX `yaml:"msix,omitempty" json:"msix,omitempty" jsonschema:"title=msix-specific settings"` } @@ -507,6 +529,31 @@ type IPKAlternative struct { LinkName string `yaml:"link_name,omitempty" json:"link_name,omitempty" jsonschema:"title=link name"` } +// XBPS contains configs that are only available on XBPS packages. +type XBPS struct { + Arch string `yaml:"arch,omitempty" json:"arch,omitempty" jsonschema:"title=architecture in xbps nomenclature"` + ShortDesc string `yaml:"short_desc,omitempty" json:"short_desc,omitempty" jsonschema:"title=short one-line package description"` + Preserve bool `yaml:"preserve,omitempty" json:"preserve,omitempty" jsonschema:"title=preserve package files on update"` + Tags []string `yaml:"tags,omitempty" json:"tags,omitempty" jsonschema:"title=package tags"` + Reverts []string `yaml:"reverts,omitempty" json:"reverts,omitempty" jsonschema:"title=versions this package reverts"` + Alternatives []XBPSAlternative `yaml:"alternatives,omitempty" json:"alternatives,omitempty" jsonschema:"title=xbps alternatives"` + Signature XBPSSignature `yaml:"signature,omitempty" json:"signature,omitempty" jsonschema:"title=xbps signature"` +} + +// XBPSAlternative represents an alternative for an XBPS package. +type XBPSAlternative struct { + Group string `yaml:"group,omitempty" json:"group,omitempty" jsonschema:"title=alternative group"` + LinkName string `yaml:"link_name,omitempty" json:"link_name,omitempty" jsonschema:"title=symlink path"` + Target string `yaml:"target,omitempty" json:"target,omitempty" jsonschema:"title=target path"` +} + +// XBPSSignature contains signing configs that are only available on XBPS packages. +type XBPSSignature struct { + KeyFile string `yaml:"key_file,omitempty" json:"key_file,omitempty" jsonschema:"title=RSA private key file"` + KeyPassphrase string `yaml:"-" json:"-"` + SignFn func(data io.Reader) ([]byte, error) `yaml:"-" json:"-"` +} + // MSIX contains configs that are only available on MSIX packages. type MSIX struct { Arch string `yaml:"arch,omitempty" json:"arch,omitempty" jsonschema:"title=architecture in msix nomenclature"` @@ -625,7 +672,7 @@ func Validate(info *Info) (err error) { if info.Name == "" { return ErrFieldEmpty{"name"} } - if info.Arch == "" && (info.Deb.Arch == "" || info.RPM.Arch == "" || info.APK.Arch == "") { + if !hasAnyArch(info) { return ErrFieldEmpty{"arch"} } if info.Version == "" { @@ -648,6 +695,17 @@ func Validate(info *Info) (err error) { return nil } +func hasAnyArch(info *Info) bool { + return info.Arch != "" || + info.Deb.Arch != "" || + info.RPM.Arch != "" || + info.APK.Arch != "" || + info.ArchLinux.Arch != "" || + info.IPK.Arch != "" || + info.XBPS.Arch != "" || + info.MSIX.Arch != "" +} + // WithDefaults set some sane defaults into the given Info. func WithDefaults(info *Info) *Info { if info.Platform == "" { diff --git a/nfpm_test.go b/nfpm_test.go index 50c7adc4..ef305f44 100644 --- a/nfpm_test.go +++ b/nfpm_test.go @@ -259,6 +259,25 @@ func TestValidate(t *testing.T) { }, })) }) + + t.Run("packager-specific arch override", func(t *testing.T) { + testCases := map[string]nfpm.Info{ + "deb": {Name: "pkg", Version: "1.2.3", Overridables: nfpm.Overridables{Deb: nfpm.Deb{Arch: "amd64"}}}, + "rpm": {Name: "pkg", Version: "1.2.3", Overridables: nfpm.Overridables{RPM: nfpm.RPM{Arch: "x86_64"}}}, + "apk": {Name: "pkg", Version: "1.2.3", Overridables: nfpm.Overridables{APK: nfpm.APK{Arch: "x86_64"}}}, + "archlinux": {Name: "pkg", Version: "1.2.3", Overridables: nfpm.Overridables{ArchLinux: nfpm.ArchLinux{Arch: "x86_64"}}}, + "ipk": {Name: "pkg", Version: "1.2.3", Overridables: nfpm.Overridables{IPK: nfpm.IPK{Arch: "all"}}}, + "xbps": {Name: "pkg", Version: "1.2.3", Overridables: nfpm.Overridables{XBPS: nfpm.XBPS{Arch: "x86_64"}}}, + "msix": {Name: "pkg", Version: "1.2.3", Overridables: nfpm.Overridables{MSIX: nfpm.MSIX{Arch: "x64"}}}, + } + + for name, info := range testCases { + name, info := name, info + t.Run(name, func(t *testing.T) { + require.NoError(t, nfpm.Validate(&info)) + }) + } + }) } func TestValidateError(t *testing.T) { @@ -377,6 +396,8 @@ func TestOptionsFromEnvironment(t *testing.T) { debPass = "password123" rpmPass = "secret" apkPass = "foobar" + xbpsPass = "xbps-secret" + nfpmXBPSPass = "nfpm-xbps-secret" platform = "linux" arch = "amd64" release = "3" @@ -460,6 +481,15 @@ maintainer: '"$GIT_COMMITTER_NAME" <$GIT_COMMITTER_EMAIL>' require.Equal(t, globalPass, info.Deb.Signature.KeyPassphrase) require.Equal(t, globalPass, info.RPM.Signature.KeyPassphrase) require.Equal(t, globalPass, info.APK.Signature.KeyPassphrase) + require.Equal(t, globalPass, info.XBPS.Signature.KeyPassphrase) + }) + + t.Run("xbps passphrase", func(t *testing.T) { + t.Setenv("NFPM_PASSPHRASE", globalPass) + t.Setenv("XBPS_PASSPHRASE", xbpsPass) + info, err := nfpm.Parse(strings.NewReader("name: foo")) + require.NoError(t, err) + require.Equal(t, xbpsPass, info.XBPS.Signature.KeyPassphrase) }) t.Run("specific passphrases", func(t *testing.T) { @@ -467,11 +497,14 @@ maintainer: '"$GIT_COMMITTER_NAME" <$GIT_COMMITTER_EMAIL>' t.Setenv("NFPM_DEB_PASSPHRASE", debPass) t.Setenv("NFPM_RPM_PASSPHRASE", rpmPass) t.Setenv("NFPM_APK_PASSPHRASE", apkPass) + t.Setenv("XBPS_PASSPHRASE", xbpsPass) + t.Setenv("NFPM_XBPS_PASSPHRASE", nfpmXBPSPass) info, err := nfpm.Parse(strings.NewReader("name: foo")) require.NoError(t, err) require.Equal(t, debPass, info.Deb.Signature.KeyPassphrase) require.Equal(t, rpmPass, info.RPM.Signature.KeyPassphrase) require.Equal(t, apkPass, info.APK.Signature.KeyPassphrase) + require.Equal(t, nfpmXBPSPass, info.XBPS.Signature.KeyPassphrase) }) t.Run("packager", func(t *testing.T) { @@ -565,6 +598,45 @@ overrides: require.Equal(t, "/debian/usr/bin/foo", content3.Destination) require.Equal(t, "foo_amd64", content3.Source) }) + + t.Run("xbps fields", func(t *testing.T) { + t.Setenv("XBPS_ARCH", "x86_64") + t.Setenv("SHORT_DESC", "short description") + t.Setenv("TAG1", "network") + t.Setenv("TAG2", "cli") + t.Setenv("REVERT1", "1.0_1") + t.Setenv("ALT_GROUP", "editor") + t.Setenv("ALT_LINK", "/usr/bin/editor") + t.Setenv("ALT_TARGET", "/usr/bin/foo") + t.Setenv("XBPS_KEY_FILE", "/keys/xbps.pem") + info, err := nfpm.Parse(strings.NewReader(` +name: foo +xbps: + arch: $XBPS_ARCH + short_desc: $SHORT_DESC + tags: + - $TAG1 + - $TAG2 + reverts: + - $REVERT1 + alternatives: + - group: $ALT_GROUP + link_name: $ALT_LINK + target: $ALT_TARGET + signature: + key_file: $XBPS_KEY_FILE +`)) + require.NoError(t, err) + require.Equal(t, "x86_64", info.XBPS.Arch) + require.Equal(t, "short description", info.XBPS.ShortDesc) + require.Equal(t, []string{"network", "cli"}, info.XBPS.Tags) + require.Equal(t, []string{"1.0_1"}, info.XBPS.Reverts) + require.Len(t, info.XBPS.Alternatives, 1) + require.Equal(t, "editor", info.XBPS.Alternatives[0].Group) + require.Equal(t, "/usr/bin/editor", info.XBPS.Alternatives[0].LinkName) + require.Equal(t, "/usr/bin/foo", info.XBPS.Alternatives[0].Target) + require.Equal(t, "/keys/xbps.pem", info.XBPS.Signature.KeyFile) + }) } func TestOverrides(t *testing.T) { diff --git a/testdata/acceptance/xbps.current.v1.txt b/testdata/acceptance/xbps.current.v1.txt new file mode 100644 index 00000000..626799f0 --- /dev/null +++ b/testdata/acceptance/xbps.current.v1.txt @@ -0,0 +1 @@ +v1 diff --git a/testdata/acceptance/xbps.current.v2.txt b/testdata/acceptance/xbps.current.v2.txt new file mode 100644 index 00000000..8c1384d8 --- /dev/null +++ b/testdata/acceptance/xbps.current.v2.txt @@ -0,0 +1 @@ +v2 diff --git a/testdata/acceptance/xbps.dockerfile b/testdata/acceptance/xbps.dockerfile new file mode 100644 index 00000000..2715e315 --- /dev/null +++ b/testdata/acceptance/xbps.dockerfile @@ -0,0 +1,73 @@ +FROM ghcr.io/void-linux/void-glibc-full:latest AS scenario + +ARG package +ARG pkgfile +ARG oldpackage +ARG oldpkgfile +ARG scenario + +RUN test -n "${package}" +RUN test -n "${pkgfile}" +RUN test -n "${oldpackage}" +RUN test -n "${oldpkgfile}" +RUN test -n "${scenario}" + +RUN mkdir -p /repo +COPY ${oldpackage} /repo/${oldpkgfile} +COPY ${package} /repo/${pkgfile} + +RUN set -eu; \ + if [ "${scenario}" = "upgrade" ]; then \ + xbps-rindex -a "/repo/${oldpkgfile}"; \ + xbps-install -i -R /repo -y foo; \ + grep 'v1' /usr/share/foo/current.txt; \ + test -f /usr/share/foo/preserve.txt; \ + xbps-rindex -a "/repo/${pkgfile}"; \ + xbps-install -i -R /repo -y -u foo; \ + else \ + xbps-rindex -a "/repo/${pkgfile}"; \ + xbps-install -i -R /repo -y foo; \ + fi + +RUN set -eu; \ + case "${scenario}" in \ + lifecycle) \ + test -x /usr/bin/fake; \ + test -f /etc/foo/whatever.conf; \ + xbps-query -p pkgver foo | grep 'foo-1.2.3_1'; \ + xbps-query -p architecture foo | grep 'x86_64'; \ + xbps-query -p conf_files foo | grep '/etc/foo/whatever.conf'; \ + test -f /tmp/preinstall-proof; \ + test -f /tmp/postinstall-proof; \ + rm -f /tmp/postinstall-proof; \ + xbps-reconfigure -f foo; \ + test -f /tmp/postinstall-proof; \ + xbps-remove -y foo; \ + test -f /tmp/preremove-proof; \ + test -f /tmp/postremove-proof; \ + test ! -e /usr/bin/fake; \ + ;; \ + metadata) \ + xbps-query -p short_desc foo | grep 'XBPS metadata package'; \ + xbps-query -p preserve foo | grep 'yes'; \ + xbps-query -p tags foo | grep 'cli'; \ + xbps-query -p tags foo | grep 'utilities'; \ + xbps-query -p reverts foo | grep '1.2.2_1'; \ + xbps-query -p alternatives foo | grep 'fake-alt'; \ + xbps-query -p alternatives foo | grep '/usr/bin/fake'; \ + ;; \ + noarch) \ + xbps-query -p architecture foo | grep 'noarch'; \ + test -x /usr/bin/fake; \ + ;; \ + upgrade) \ + xbps-query -p pkgver foo | grep 'foo-1.2.4_1'; \ + xbps-query -p preserve foo | grep 'yes'; \ + grep 'v2' /usr/share/foo/current.txt; \ + test -f /usr/share/foo/preserve.txt; \ + ;; \ + *) \ + echo "unknown scenario: ${scenario}" >&2; \ + exit 1; \ + ;; \ + esac diff --git a/testdata/acceptance/xbps.lifecycle.yaml b/testdata/acceptance/xbps.lifecycle.yaml new file mode 100644 index 00000000..c874a937 --- /dev/null +++ b/testdata/acceptance/xbps.lifecycle.yaml @@ -0,0 +1,25 @@ +name: "foo" +arch: "${BUILD_ARCH}" +platform: "linux" +version: "v1.2.3" +release: "1" +maintainer: "Foo Bar" +description: | + Foo bar + Multiple lines +vendor: "foobar" +homepage: "https://foobar.org" +license: "MIT" +contents: + - src: ./testdata/fake + dst: /usr/bin/fake + - src: ./testdata/whatever.conf + dst: /etc/foo/whatever.conf + type: config +scripts: + preinstall: ./testdata/acceptance/scripts/preinstall.sh + postinstall: ./testdata/acceptance/scripts/postinstall.sh + preremove: ./testdata/acceptance/scripts/preremove.sh + postremove: ./testdata/acceptance/scripts/postremove.sh +xbps: + short_desc: "XBPS lifecycle package" diff --git a/testdata/acceptance/xbps.metadata.yaml b/testdata/acceptance/xbps.metadata.yaml new file mode 100644 index 00000000..32facd1a --- /dev/null +++ b/testdata/acceptance/xbps.metadata.yaml @@ -0,0 +1,27 @@ +name: "foo" +arch: "${BUILD_ARCH}" +platform: "linux" +version: "v1.2.3" +release: "1" +maintainer: "Foo Bar" +description: | + Foo bar + Multiple lines +vendor: "foobar" +homepage: "https://foobar.org" +license: "MIT" +contents: + - src: ./testdata/fake + dst: /usr/bin/fake +xbps: + short_desc: "XBPS metadata package" + preserve: true + tags: + - utilities + - cli + reverts: + - 1.2.2_1 + alternatives: + - group: fake + link_name: /usr/bin/fake-alt + target: /usr/bin/fake diff --git a/testdata/acceptance/xbps.noarch.yaml b/testdata/acceptance/xbps.noarch.yaml new file mode 100644 index 00000000..eb14025a --- /dev/null +++ b/testdata/acceptance/xbps.noarch.yaml @@ -0,0 +1,17 @@ +name: "foo" +arch: "all" +platform: "linux" +version: "v1.2.3" +release: "1" +maintainer: "Foo Bar" +description: | + Foo bar + Multiple lines +vendor: "foobar" +homepage: "https://foobar.org" +license: "MIT" +contents: + - src: ./testdata/fake + dst: /usr/bin/fake +xbps: + short_desc: "XBPS noarch package" diff --git a/testdata/acceptance/xbps.preserve.v1.txt b/testdata/acceptance/xbps.preserve.v1.txt new file mode 100644 index 00000000..90824e1c --- /dev/null +++ b/testdata/acceptance/xbps.preserve.v1.txt @@ -0,0 +1 @@ +preserved diff --git a/testdata/acceptance/xbps.upgrade.v1.yaml b/testdata/acceptance/xbps.upgrade.v1.yaml new file mode 100644 index 00000000..11ade54a --- /dev/null +++ b/testdata/acceptance/xbps.upgrade.v1.yaml @@ -0,0 +1,20 @@ +name: "foo" +arch: "${BUILD_ARCH}" +platform: "linux" +version: "v1.2.3" +release: "1" +maintainer: "Foo Bar" +description: | + Foo bar + Multiple lines +vendor: "foobar" +homepage: "https://foobar.org" +license: "MIT" +contents: + - src: ./testdata/acceptance/xbps.current.v1.txt + dst: /usr/share/foo/current.txt + - src: ./testdata/acceptance/xbps.preserve.v1.txt + dst: /usr/share/foo/preserve.txt +xbps: + short_desc: "XBPS upgrade package" + preserve: true diff --git a/testdata/acceptance/xbps.upgrade.v2.yaml b/testdata/acceptance/xbps.upgrade.v2.yaml new file mode 100644 index 00000000..c626c31f --- /dev/null +++ b/testdata/acceptance/xbps.upgrade.v2.yaml @@ -0,0 +1,18 @@ +name: "foo" +arch: "${BUILD_ARCH}" +platform: "linux" +version: "v1.2.4" +release: "1" +maintainer: "Foo Bar" +description: | + Foo bar + Multiple lines +vendor: "foobar" +homepage: "https://foobar.org" +license: "MIT" +contents: + - src: ./testdata/acceptance/xbps.current.v2.txt + dst: /usr/share/foo/current.txt +xbps: + short_desc: "XBPS upgrade package" + preserve: true diff --git a/www/content/docs/_index.md b/www/content/docs/_index.md index 1d9f8eef..559150b5 100644 --- a/www/content/docs/_index.md +++ b/www/content/docs/_index.md @@ -15,7 +15,7 @@ This is a subtle way of saying it won't have all features, nor all formats that ## Features - **Zero Dependencies**: No Ruby, no tar, no external dependencies -- **Multiple Formats**: deb, rpm, apk, ipk, arch linux, and msix packages +- **Multiple Formats**: deb, rpm, apk, ipk, arch linux, msix, and xbps packages - **Simple Configuration**: Single YAML file for all package formats - **Cross Platform**: Build on any platform Go supports - **Fast**: Written in Go for speed and efficiency diff --git a/www/content/docs/arch-mapping.md b/www/content/docs/arch-mapping.md index 20690a34..03e8ad4c 100644 --- a/www/content/docs/arch-mapping.md +++ b/www/content/docs/arch-mapping.md @@ -20,7 +20,7 @@ Thank you! --- -{{< tabs items="Deb,RPM,APK,Arch Linux,IPK,MSIX" >}} +{{< tabs items="Deb,RPM,APK,Arch Linux,IPK,XBPS,MSIX" >}} {{< tab >}} @@ -121,6 +121,24 @@ Thank you! {{< tab >}} +| Input | Value | +| :-------: | :-------: | +| `all` | `noarch` | +| `noarch` | `noarch` | +| `amd64` | `x86_64` | +| `x86_64` | `x86_64` | +| `386` | `i686` | +| `i386` | `i686` | +| `i686` | `i686` | +| `arm64` | `aarch64` | +| `aarch64` | `aarch64` | +| `arm6` | `armv6l` | +| `arm7` | `armv7l` | + +{{< /tab >}} + +{{< tab >}} + | Input | Value | | :-------: | :-------: | | `amd64` | `x64` | diff --git a/www/content/docs/cmd/nfpm.md b/www/content/docs/cmd/nfpm.md index 573d6e29..8a174969 100644 --- a/www/content/docs/cmd/nfpm.md +++ b/www/content/docs/cmd/nfpm.md @@ -2,11 +2,11 @@ title: nfpm --- -Packages apps on RPM, Deb, APK, Arch Linux, ipk, and MSIX formats based on a YAML configuration file +Packages apps on RPM, Deb, APK, Arch Linux, ipk, MSIX, and XBPS formats based on a YAML configuration file ## Synopsis -nFPM is a simple and 0-dependencies apk, arch, deb, ipk, msix, and rpm packager written in Go. +nFPM is a simple and 0-dependencies apk, arch, deb, ipk, msix, rpm, and xbps packager written in Go. ## Options diff --git a/www/content/docs/cmd/nfpm_completion.md b/www/content/docs/cmd/nfpm_completion.md index dab2512d..ea4a4ba1 100644 --- a/www/content/docs/cmd/nfpm_completion.md +++ b/www/content/docs/cmd/nfpm_completion.md @@ -18,7 +18,7 @@ See each sub-command's help for details on how to use the generated script. ## See also -* [nfpm](/docs/cmd/nfpm/) - Packages apps on RPM, Deb, APK, Arch Linux, ipk, and MSIX formats based on a YAML configuration file +* [nfpm](/docs/cmd/nfpm/) - Packages apps on RPM, Deb, APK, Arch Linux, ipk, MSIX, and XBPS formats based on a YAML configuration file * [nfpm completion bash](/docs/cmd/nfpm_completion_bash/) - Generate the autocompletion script for bash * [nfpm completion fish](/docs/cmd/nfpm_completion_fish/) - Generate the autocompletion script for fish * [nfpm completion powershell](/docs/cmd/nfpm_completion_powershell/) - Generate the autocompletion script for powershell diff --git a/www/content/docs/cmd/nfpm_init.md b/www/content/docs/cmd/nfpm_init.md index 89044f51..103c9d77 100644 --- a/www/content/docs/cmd/nfpm_init.md +++ b/www/content/docs/cmd/nfpm_init.md @@ -17,5 +17,5 @@ nfpm init [flags] ## See also -* [nfpm](/docs/cmd/nfpm/) - Packages apps on RPM, Deb, APK, Arch Linux, ipk, and MSIX formats based on a YAML configuration file +* [nfpm](/docs/cmd/nfpm/) - Packages apps on RPM, Deb, APK, Arch Linux, ipk, MSIX, and XBPS formats based on a YAML configuration file diff --git a/www/content/docs/cmd/nfpm_jsonschema.md b/www/content/docs/cmd/nfpm_jsonschema.md index 7af34053..e62a4d2c 100644 --- a/www/content/docs/cmd/nfpm_jsonschema.md +++ b/www/content/docs/cmd/nfpm_jsonschema.md @@ -17,5 +17,5 @@ nfpm jsonschema [flags] ## See also -* [nfpm](/docs/cmd/nfpm/) - Packages apps on RPM, Deb, APK, Arch Linux, ipk, and MSIX formats based on a YAML configuration file +* [nfpm](/docs/cmd/nfpm/) - Packages apps on RPM, Deb, APK, Arch Linux, ipk, MSIX, and XBPS formats based on a YAML configuration file diff --git a/www/content/docs/cmd/nfpm_package.md b/www/content/docs/cmd/nfpm_package.md index 5f52f69b..41cf0660 100644 --- a/www/content/docs/cmd/nfpm_package.md +++ b/www/content/docs/cmd/nfpm_package.md @@ -13,11 +13,11 @@ nfpm package [flags] ``` -f, --config string config file to be used (default "nfpm.yaml") -h, --help help for package - -p, --packager string which packager implementation to use [apk|archlinux|deb|ipk|msix|rpm|srpm] + -p, --packager string which packager implementation to use [apk|archlinux|deb|ipk|msix|rpm|srpm|xbps] -t, --target string where to save the generated package (filename, folder or empty for current folder) ``` ## See also -* [nfpm](/docs/cmd/nfpm/) - Packages apps on RPM, Deb, APK, Arch Linux, ipk, and MSIX formats based on a YAML configuration file +* [nfpm](/docs/cmd/nfpm/) - Packages apps on RPM, Deb, APK, Arch Linux, ipk, MSIX, and XBPS formats based on a YAML configuration file diff --git a/www/content/docs/configuration.md b/www/content/docs/configuration.md index a5f95562..0d4ff2e7 100644 --- a/www/content/docs/configuration.md +++ b/www/content/docs/configuration.md @@ -550,6 +550,48 @@ ipk: target: /usr/bin/vim priority: 50 +# Custom configuration applied only to the XBPS packager. +xbps: + # xbps specific architecture name that overrides "arch" without performing any replacements. + arch: x86_64 + + # XBPS short package description. If unset, the first line of the generic + # description is used. The generic description maps to the XBPS long + # description. + short_desc: Explicit short description for the package + + # Preserve package files on update. + preserve: true + + # Package tags. + tags: + - cli + - utilities + + # Versions this package reverts. + reverts: + - 1.2.2_1 + + # Alternatives allow this package to provide a generic command via symlinks. + alternatives: + - group: editor + link_name: /usr/bin/editor + target: /usr/bin/foo + + # XBPS config-file metadata is derived from contents with type `config`, + # `config|noreplace`, or `config|missingok`; there is no separate XBPS + # config-files list. + + # XBPS package signature sidecar output. When key_file is set, nFPM writes + # an adjacent .xbps.sig2 file for the generated package. + # The passphrase is taken from NFPM_XBPS_PASSPHRASE, with fallbacks to + # XBPS_PASSPHRASE and NFPM_PASSPHRASE. + # This does not create or sign repository metadata, publish repositories, + # manage remote repositories, or orchestrate xbps-rindex --sign. + signature: + # RSA private key in PEM format. + key_file: key.pem + # Custom configuration applied only to the MSIX packager (Windows). msix: # msix specific architecture name that overrides "arch" without performing diff --git a/www/content/docs/quick-start.md b/www/content/docs/quick-start.md index e9eaaa1e..4b726085 100644 --- a/www/content/docs/quick-start.md +++ b/www/content/docs/quick-start.md @@ -47,10 +47,34 @@ Use [`nfpm package`](/docs/cmd/nfpm_package) to create your packages: nfpm pkg --packager deb --target /tmp/ nfpm pkg --packager rpm --target /tmp/ nfpm pkg --packager apk --target /tmp/ +nfpm pkg --packager xbps --target /tmp/ ``` You can also use `ipk`, `archlinux`, and `msix` as packagers. +### Verify an XBPS package on Void Linux + +nFPM generates XBPS packages natively. Compatibility is tested primarily against +Void Linux; other XBPS-based environments may also work, but they are secondary +targets. + +In a disposable Void environment, a local repository smoke check can use the +standard XBPS tooling: + +```sh +repo="$(mktemp -d)" +nfpm pkg --packager xbps --target "$repo/" +xbps-rindex -a "$repo"/*.xbps +xbps-install -i -R "$repo" -y foo +xbps-query -p pkgver foo +xbps-remove -y foo +``` + +When `xbps.signature.key_file` is configured, nFPM writes an adjacent +`.xbps.sig2` sidecar for the generated package. This does not create or +sign repository metadata, publish repositories, manage remote repositories, or +orchestrate `xbps-rindex --sign`. + {{% /steps %}} ## Command Line Reference diff --git a/www/static/schema.json b/www/static/schema.json index 1200c064..822022d8 100644 --- a/www/static/schema.json +++ b/www/static/schema.json @@ -196,6 +196,10 @@ "$ref": "#/$defs/IPK", "title": "ipk-specific settings" }, + "xbps": { + "$ref": "#/$defs/XBPS", + "title": "xbps-specific settings" + }, "msix": { "$ref": "#/$defs/MSIX", "title": "msix-specific settings" @@ -952,6 +956,10 @@ "$ref": "#/$defs/IPK", "title": "ipk-specific settings" }, + "xbps": { + "$ref": "#/$defs/XBPS", + "title": "xbps-specific settings" + }, "msix": { "$ref": "#/$defs/MSIX", "title": "msix-specific settings" @@ -1094,6 +1102,77 @@ }, "additionalProperties": false, "type": "object" + }, + "XBPS": { + "properties": { + "arch": { + "type": "string", + "title": "architecture in xbps nomenclature" + }, + "short_desc": { + "type": "string", + "title": "short one-line package description" + }, + "preserve": { + "type": "boolean", + "title": "preserve package files on update" + }, + "tags": { + "items": { + "type": "string" + }, + "type": "array", + "title": "package tags" + }, + "reverts": { + "items": { + "type": "string" + }, + "type": "array", + "title": "versions this package reverts" + }, + "alternatives": { + "items": { + "$ref": "#/$defs/XBPSAlternative" + }, + "type": "array", + "title": "xbps alternatives" + }, + "signature": { + "$ref": "#/$defs/XBPSSignature", + "title": "xbps signature" + } + }, + "additionalProperties": false, + "type": "object" + }, + "XBPSAlternative": { + "properties": { + "group": { + "type": "string", + "title": "alternative group" + }, + "link_name": { + "type": "string", + "title": "symlink path" + }, + "target": { + "type": "string", + "title": "target path" + } + }, + "additionalProperties": false, + "type": "object" + }, + "XBPSSignature": { + "properties": { + "key_file": { + "type": "string", + "title": "RSA private key file" + } + }, + "additionalProperties": false, + "type": "object" } }, "description": "nFPM configuration definition file" diff --git a/xbps/xbps.go b/xbps/xbps.go new file mode 100644 index 00000000..14f3a3fe --- /dev/null +++ b/xbps/xbps.go @@ -0,0 +1,736 @@ +// Package xbps implements nfpm.Packager providing .xbps bindings. +package xbps + +import ( + "archive/tar" + "bytes" + "crypto/sha256" + "fmt" + "io" + "os" + "slices" + "sort" + "strconv" + "strings" + + "github.com/goreleaser/nfpm/v2" + "github.com/goreleaser/nfpm/v2/files" + "github.com/goreleaser/nfpm/v2/internal/sign" + "github.com/klauspost/compress/zstd" +) + +const packagerName = "xbps" + +// nolint: gochecknoinits +func init() { + nfpm.RegisterPackager(packagerName, Default) +} + +// nolint: gochecknoglobals +var archToXBPS = map[string]string{ + "all": "noarch", + "noarch": "noarch", + "amd64": "x86_64", + "x86_64": "x86_64", + "386": "i686", + "i386": "i686", + "i686": "i686", + "arm64": "aarch64", + "aarch64": "aarch64", + "arm6": "armv6l", + "arm7": "armv7l", +} + +// Default XBPS packager. +// nolint: gochecknoglobals +var Default = &XBPS{} + +// XBPS packager implementation. +type XBPS struct{} + +func ensureValidArch(info *nfpm.Info) (*nfpm.Info, error) { + if info.XBPS.Arch != "" { + info.Arch = info.XBPS.Arch + return info, nil + } + + arch, ok := archToXBPS[info.Arch] + if !ok { + return nil, fmt.Errorf("xbps: unsupported architecture %q", info.Arch) + } + info.Arch = arch + return info, nil +} + +func normalizeVersionPart(value string) string { + value = strings.TrimSpace(value) + value = strings.Trim(value, "-") + value = strings.Trim(value, ".") + return value +} + +func version(info *nfpm.Info) string { + base := strings.TrimSpace(info.Version) + base = strings.TrimPrefix(base, "v") + + parts := []string{base} + if pre := normalizeVersionPart(info.Prerelease); pre != "" { + parts = append(parts, pre) + } + if meta := normalizeVersionPart(info.VersionMetadata); meta != "" { + parts = append(parts, meta) + } + return strings.Join(parts, ".") +} + +func revision(info *nfpm.Info) (string, error) { + trimmed := strings.TrimSpace(info.Release) + if trimmed == "" { + return "1", nil + } + + rev, err := strconv.Atoi(trimmed) + if err != nil || rev < 1 { + return "", fmt.Errorf("xbps: release %q must be a positive integer revision", info.Release) + } + return trimmed, nil +} + +func pkgver(info *nfpm.Info) (string, error) { + rev, err := revision(info) + if err != nil { + return "", err + } + return fmt.Sprintf("%s-%s_%s", info.Name, version(info), rev), nil +} + +func shortDesc(info *nfpm.Info) string { + if desc := strings.TrimSpace(info.XBPS.ShortDesc); desc != "" { + return desc + } + first, _, _ := strings.Cut(strings.TrimSpace(info.Description), "\n") + return strings.TrimSpace(first) +} + +func sortedContents(info *nfpm.Info) files.Contents { + contents := slices.Clone(info.Contents) + sort.Sort(contents) + return contents +} + +func sortedStrings(values []string) []string { + result := slices.Clone(values) + sort.Strings(result) + return result +} + +func isConfigType(contentType string) bool { + switch contentType { + case files.TypeConfig, files.TypeConfigNoReplace, files.TypeConfigMissingOK: + return true + default: + return false + } +} + +func isPayloadFileType(contentType string) bool { + switch contentType { + case files.TypeDir, files.TypeImplicitDir, files.TypeSymlink, files.TypeRPMGhost: + return false + default: + return true + } +} + +func installedSize(info *nfpm.Info) uint64 { + var total uint64 + for _, content := range sortedContents(info) { + if isPayloadFileType(content.Type) { + total += uint64(content.Size()) + } + } + return total +} + +func configFiles(info *nfpm.Info) []string { + var result []string + for _, content := range sortedContents(info) { + if isConfigType(content.Type) { + result = append(result, files.NormalizeAbsoluteFilePath(content.Destination)) + } + } + return result +} + +func stringsToPlistArray(values []string) plistArray { + items := plistArray{} + for _, value := range sortedStrings(values) { + items = append(items, value) + } + return items +} + +func invalidAlternativePart(value string) bool { + return value == "" || strings.ContainsAny(value, ": \t\n\r") +} + +func validateAlternative(alt nfpm.XBPSAlternative) error { + switch { + case invalidAlternativePart(alt.Group): + return fmt.Errorf("xbps: invalid alternative group %q", alt.Group) + case invalidAlternativePart(alt.LinkName): + return fmt.Errorf("xbps: invalid alternative link_name %q", alt.LinkName) + case invalidAlternativePart(alt.Target): + return fmt.Errorf("xbps: invalid alternative target %q", alt.Target) + default: + return nil + } +} + +func sortedAlternatives(values []nfpm.XBPSAlternative) []nfpm.XBPSAlternative { + result := slices.Clone(values) + sort.Slice(result, func(i, j int) bool { + if result[i].Group != result[j].Group { + return result[i].Group < result[j].Group + } + if result[i].LinkName != result[j].LinkName { + return result[i].LinkName < result[j].LinkName + } + return result[i].Target < result[j].Target + }) + return result +} + +func alternativesMetadata(info *nfpm.Info) (plistDict, error) { + result := plistDict{} + for _, alt := range sortedAlternatives(info.XBPS.Alternatives) { + if err := validateAlternative(alt); err != nil { + return nil, err + } + item := fmt.Sprintf("%s:%s", alt.LinkName, alt.Target) + items, _ := result[alt.Group].(plistArray) + result[alt.Group] = append(items, item) + } + return result, nil +} + +type plistValue any + +type plistDict map[string]plistValue + +type plistArray []plistValue + +func portablePlistEscape(buf *bytes.Buffer, value string) { + for _, r := range value { + switch r { + case '&': + buf.WriteString("&") + case '<': + buf.WriteString("<") + case '>': + buf.WriteString(">") + default: + buf.WriteRune(r) + } + } +} + +func writePlistValue(buf *bytes.Buffer, value plistValue) error { + switch v := value.(type) { + case plistDict: + buf.WriteString("") + keys := make([]string, 0, len(v)) + for key := range v { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + buf.WriteString("") + portablePlistEscape(buf, key) + buf.WriteString("") + if err := writePlistValue(buf, v[key]); err != nil { + return err + } + } + buf.WriteString("") + case plistArray: + buf.WriteString("") + for _, item := range v { + if err := writePlistValue(buf, item); err != nil { + return err + } + } + buf.WriteString("") + case string: + buf.WriteString("") + portablePlistEscape(buf, v) + buf.WriteString("") + case bool: + if v { + buf.WriteString("") + } else { + buf.WriteString("") + } + case int64: + fmt.Fprintf(buf, "%d", v) + case uint64: + fmt.Fprintf(buf, "%d", v) + default: + return fmt.Errorf("xbps: unsupported plist value type %T", value) + } + return nil +} + +func marshalPlist(root plistDict) ([]byte, error) { + var buf bytes.Buffer + buf.WriteString("\n") + buf.WriteString("\n") + buf.WriteString("\n") + if err := writePlistValue(&buf, root); err != nil { + return nil, err + } + buf.WriteString("\n\n") + return buf.Bytes(), nil +} + +func propsManifest(info *nfpm.Info) (plistDict, error) { + copyInfo := *info + normalized, err := ensureValidArch(©Info) + if err != nil { + return nil, err + } + pv, err := pkgver(info) + if err != nil { + return nil, err + } + + manifest := plistDict{ + "architecture": normalized.Arch, + "installed_size": installedSize(info), + "pkgname": info.Name, + "pkgver": pv, + "short_desc": shortDesc(info), + "version": version(info), + } + if info.Description != "" { + manifest["long_desc"] = info.Description + } + if info.Homepage != "" { + manifest["homepage"] = info.Homepage + } + if info.License != "" { + manifest["license"] = info.License + } + if info.Maintainer != "" { + manifest["maintainer"] = info.Maintainer + } + if len(info.Depends) > 0 { + manifest["run_depends"] = stringsToPlistArray(info.Depends) + } + if confs := configFiles(info); len(confs) > 0 { + manifest["conf_files"] = stringsToPlistArray(confs) + } + for key, values := range map[string][]string{ + "conflicts": info.Conflicts, + "provides": info.Provides, + "replaces": info.Replaces, + "reverts": info.XBPS.Reverts, + } { + if len(values) == 0 { + continue + } + manifest[key] = stringsToPlistArray(values) + } + if len(info.XBPS.Tags) > 0 { + manifest["tags"] = strings.Join(sortedStrings(info.XBPS.Tags), " ") + } + if info.XBPS.Preserve { + manifest["preserve"] = true + } + if len(info.XBPS.Alternatives) > 0 { + alternatives, err := alternativesMetadata(info) + if err != nil { + return nil, err + } + manifest["alternatives"] = alternatives + } + return manifest, nil +} + +func fileEntry(content *files.Content) (plistDict, error) { + entry := plistDict{ + "file": files.NormalizeAbsoluteFilePath(content.Destination), + } + if content.Type == files.TypeSymlink { + // Record the symlink target exactly as it is written into the tar + // header (writeContentEntry sets Linkname: content.Source), so the + // files.plist metadata never disagrees with the payload for relative + // targets. This matches the arch packager's MTREE handling. + entry["target"] = content.Source + return entry, nil + } + if content.Type == files.TypeDir || content.Type == files.TypeImplicitDir { + return entry, nil + } + + f, err := os.Open(content.Source) + if err != nil { + return nil, err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return nil, err + } + entry["sha256"] = fmt.Sprintf("%x", h.Sum(nil)) + entry["size"] = uint64(content.Size()) + return entry, nil +} + +func filesManifest(info *nfpm.Info) (plistDict, error) { + manifest := plistDict{} + var regular plistArray + var configs plistArray + var links plistArray + var dirs plistArray + + for _, content := range sortedContents(info) { + switch { + case content.Type == files.TypeRPMGhost: + continue + case content.Type == files.TypeDir || content.Type == files.TypeImplicitDir: + entry, err := fileEntry(content) + if err != nil { + return nil, err + } + dirs = append(dirs, entry) + case content.Type == files.TypeSymlink: + entry, err := fileEntry(content) + if err != nil { + return nil, err + } + links = append(links, entry) + case isConfigType(content.Type): + entry, err := fileEntry(content) + if err != nil { + return nil, err + } + configs = append(configs, entry) + default: + entry, err := fileEntry(content) + if err != nil { + return nil, err + } + regular = append(regular, entry) + } + } + if len(regular) > 0 { + manifest["files"] = regular + } + if len(configs) > 0 { + manifest["conf_files"] = configs + } + if len(links) > 0 { + manifest["links"] = links + } + if len(dirs) > 0 { + manifest["dirs"] = dirs + } + return manifest, nil +} + +type xbpsScriptlet struct { + action string + name string + source string +} + +func loadOptionalScript(source string) ([]byte, error) { + if source == "" { + return nil, nil + } + return os.ReadFile(source) +} + +func appendScriptFunction(buf *bytes.Buffer, name string, data []byte) { + fmt.Fprintf(buf, "%s() {\n", name) + buf.Write(data) + if len(data) > 0 && data[len(data)-1] != '\n' { + buf.WriteByte('\n') + } + buf.WriteString("}\n\n") +} + +func renderXBPSActionScript(scriptlets []xbpsScriptlet) ([]byte, error) { + var loaded []xbpsScriptlet + bodies := map[string][]byte{} + for _, scriptlet := range scriptlets { + data, err := loadOptionalScript(scriptlet.source) + if err != nil { + return nil, err + } + if len(data) == 0 { + continue + } + loaded = append(loaded, scriptlet) + bodies[scriptlet.name] = data + } + if len(loaded) == 0 { + return nil, nil + } + + var buf bytes.Buffer + buf.WriteString("#!/bin/sh\n\n") + for _, scriptlet := range loaded { + appendScriptFunction(&buf, scriptlet.name, bodies[scriptlet.name]) + } + + buf.WriteString("case \"$1\" in\n") + for _, scriptlet := range loaded { + fmt.Fprintf(&buf, "%s)\n\t%s\n\t;;\n", scriptlet.action, scriptlet.name) + } + buf.WriteString("esac\n") + return buf.Bytes(), nil +} + +func renderInstallScript(info *nfpm.Info) ([]byte, error) { + return renderXBPSActionScript([]xbpsScriptlet{ + {action: "pre", name: "preinstall", source: info.Scripts.PreInstall}, + {action: "post", name: "postinstall", source: info.Scripts.PostInstall}, + }) +} + +func renderRemoveScript(info *nfpm.Info) ([]byte, error) { + return renderXBPSActionScript([]xbpsScriptlet{ + {action: "pre", name: "preremove", source: info.Scripts.PreRemove}, + {action: "post", name: "postremove", source: info.Scripts.PostRemove}, + }) +} + +// ConventionalFileName returns a file name according to XBPS package conventions. +func (*XBPS) ConventionalFileName(info *nfpm.Info) string { + copyInfo := *info + normalized, err := ensureValidArch(©Info) + if err != nil { + normalized = ©Info + } + + pv, err := pkgver(normalized) + if err != nil { + pv = fmt.Sprintf("%s-%s_1", info.Name, version(info)) + } + return fmt.Sprintf("%s.%s.xbps", pv, normalized.Arch) +} + +// ConventionalExtension returns the file name conventionally used for XBPS packages. +func (*XBPS) ConventionalExtension() string { + return ".xbps" +} + +func writeBytesEntry(tw *tar.Writer, name string, data []byte, mode int64, info *nfpm.Info) error { + if err := tw.WriteHeader(&tar.Header{ + Name: name, + Mode: mode, + Size: int64(len(data)), + Typeflag: tar.TypeReg, + ModTime: info.MTime, + Uname: "root", + Gname: "root", + Uid: 0, + Gid: 0, + }); err != nil { + return err + } + _, err := tw.Write(data) + return err +} + +func packageShouldBeSigned(info *nfpm.Info) bool { + return info.XBPS.Signature.KeyFile != "" || info.XBPS.Signature.SignFn != nil +} + +func requireSignatureTarget(info *nfpm.Info) error { + if strings.TrimSpace(info.Target) == "" { + return &nfpm.ErrSigningFailure{Err: fmt.Errorf("xbps: target path required for signature sidecar")} + } + return nil +} + +func signPackageDigest(info *nfpm.Info, digest []byte) ([]byte, error) { + if info.XBPS.Signature.SignFn != nil { + signature, err := info.XBPS.Signature.SignFn(bytes.NewReader(digest)) + if err != nil { + return nil, fmt.Errorf("sign package digest: %w", err) + } + return signature, nil + } + return sign.RSASignSHA256Digest(digest, info.XBPS.Signature.KeyFile, info.XBPS.Signature.KeyPassphrase) +} + +func writeSignatureSidecar(info *nfpm.Info, digest []byte) error { + signature, err := signPackageDigest(info, digest) + if err != nil { + return &nfpm.ErrSigningFailure{Err: err} + } + if err := os.WriteFile(info.Target+".sig2", signature, 0o644); err != nil { + return &nfpm.ErrSigningFailure{Err: fmt.Errorf("write signature sidecar: %w", err)} + } + return nil +} + +func writeContentEntry(tw *tar.Writer, content *files.Content) error { + name := files.AsExplicitRelativePath(content.Destination) + switch content.Type { + case files.TypeDir, files.TypeImplicitDir: + return tw.WriteHeader(&tar.Header{ + Name: name, + Mode: int64(content.Mode()), + Typeflag: tar.TypeDir, + ModTime: content.ModTime(), + Uname: content.FileInfo.Owner, + Gname: content.FileInfo.Group, + Uid: 0, + Gid: 0, + }) + case files.TypeSymlink: + return tw.WriteHeader(&tar.Header{ + Name: name, + Mode: 0o777, + Typeflag: tar.TypeSymlink, + Linkname: content.Source, + ModTime: content.ModTime(), + Uname: content.FileInfo.Owner, + Gname: content.FileInfo.Group, + Uid: 0, + Gid: 0, + }) + default: + f, err := os.Open(content.Source) + if err != nil { + return err + } + defer f.Close() + if err := tw.WriteHeader(&tar.Header{ + Name: name, + Mode: int64(content.Mode()), + Size: content.Size(), + Typeflag: tar.TypeReg, + ModTime: content.ModTime(), + Uname: content.FileInfo.Owner, + Gname: content.FileInfo.Group, + Uid: 0, + Gid: 0, + }); err != nil { + return err + } + _, err = io.Copy(tw, f) + return err + } +} + +// Package writes a new xbps package to the given writer using the given info. +func (*XBPS) Package(info *nfpm.Info, w io.Writer) error { + if info.Platform != "linux" { + return fmt.Errorf("invalid platform: %s", info.Platform) + } + var err error + if info, err = ensureValidArch(info); err != nil { + return err + } + if _, err := pkgver(info); err != nil { + return err + } + if err := nfpm.PrepareForPackager(info, packagerName); err != nil { + return err + } + + props, err := propsManifest(info) + if err != nil { + return err + } + propsData, err := marshalPlist(props) + if err != nil { + return err + } + manifest, err := filesManifest(info) + if err != nil { + return err + } + manifestData, err := marshalPlist(manifest) + if err != nil { + return err + } + installScript, err := renderInstallScript(info) + if err != nil { + return err + } + removeScript, err := renderRemoveScript(info) + if err != nil { + return err + } + + packageWriter := w + var packageDigest func() []byte + if packageShouldBeSigned(info) { + if err := requireSignatureTarget(info); err != nil { + return err + } + h := sha256.New() + packageWriter = io.MultiWriter(w, h) + packageDigest = func() []byte { return h.Sum(nil) } + } + + zw, err := zstd.NewWriter(packageWriter) + if err != nil { + return fmt.Errorf("xbps: create zstd writer: %w", err) + } + tw := tar.NewWriter(zw) + if err := writeBytesEntry(tw, "./props.plist", propsData, 0o644, info); err != nil { + _ = tw.Close() + _ = zw.Close() + return err + } + if err := writeBytesEntry(tw, "./files.plist", manifestData, 0o644, info); err != nil { + _ = tw.Close() + _ = zw.Close() + return err + } + if len(installScript) > 0 { + if err := writeBytesEntry(tw, "./INSTALL", installScript, 0o755, info); err != nil { + _ = tw.Close() + _ = zw.Close() + return err + } + } + if len(removeScript) > 0 { + if err := writeBytesEntry(tw, "./REMOVE", removeScript, 0o755, info); err != nil { + _ = tw.Close() + _ = zw.Close() + return err + } + } + for _, content := range sortedContents(info) { + if content.Type == files.TypeRPMGhost { + continue + } + if err := writeContentEntry(tw, content); err != nil { + _ = tw.Close() + _ = zw.Close() + return err + } + } + if err := tw.Close(); err != nil { + _ = zw.Close() + return fmt.Errorf("xbps: close tar writer: %w", err) + } + if err := zw.Close(); err != nil { + return fmt.Errorf("xbps: close zstd writer: %w", err) + } + if packageDigest != nil { + if err := writeSignatureSidecar(info, packageDigest()); err != nil { + return err + } + } + return nil +} diff --git a/xbps/xbps_test.go b/xbps/xbps_test.go new file mode 100644 index 00000000..a0c5dfe9 --- /dev/null +++ b/xbps/xbps_test.go @@ -0,0 +1,655 @@ +package xbps + +import ( + "archive/tar" + "bytes" + "crypto/sha256" + "io" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/goreleaser/nfpm/v2" + "github.com/goreleaser/nfpm/v2/files" + "github.com/goreleaser/nfpm/v2/internal/sign" + "github.com/klauspost/compress/zstd" + "github.com/stretchr/testify/require" +) + +var testMTime = time.Date(2023, 11, 5, 23, 15, 17, 0, time.UTC) + +func exampleInfo() *nfpm.Info { + return nfpm.WithDefaults(&nfpm.Info{ + Name: "foo", + Arch: "amd64", + Version: "v1.2.3", + Prerelease: "beta-1", + VersionMetadata: "git-1", + Release: "2", + Description: "Foo does things\nAnd does them well.", + Maintainer: "Carlos A Becker ", + Homepage: "https://example.com/foo", + License: "MIT", + MTime: testMTime, + Overridables: nfpm.Overridables{ + Depends: []string{"zlib>=1.0_1", "bash"}, + Provides: []string{"foo-virtual-1.0_1"}, + Replaces: []string{"foo-old>=1.0"}, + Conflicts: []string{"bar<2.0"}, + Contents: []*files.Content{ + { + Source: "../testdata/fake", + Destination: "/usr/bin/fake", + }, + { + Source: "../testdata/whatever.conf", + Destination: "/etc/fake/fake.conf", + Type: files.TypeConfig, + }, + { + Destination: "/var/lib/foo", + Type: files.TypeDir, + }, + { + Source: "/etc/fake/fake.conf", + Destination: "/etc/fake/fake-link.conf", + Type: files.TypeSymlink, + }, + }, + }, + }) +} + +func readTarEntries(t *testing.T, data []byte) map[string][]byte { + t.Helper() + zr, err := zstd.NewReader(bytes.NewReader(data)) + require.NoError(t, err) + defer zr.Close() + + tr := tar.NewReader(zr) + entries := map[string][]byte{} + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + require.NoError(t, err) + body, err := io.ReadAll(tr) + require.NoError(t, err) + entries[hdr.Name] = body + } + return entries +} + +func readTarNames(t *testing.T, data []byte) []string { + t.Helper() + zr, err := zstd.NewReader(bytes.NewReader(data)) + require.NoError(t, err) + defer zr.Close() + + tr := tar.NewReader(zr) + var names []string + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + require.NoError(t, err) + names = append(names, hdr.Name) + _, err = io.Copy(io.Discard, tr) + require.NoError(t, err) + } + return names +} + +func writeTempScript(t *testing.T, dir, name, body string) string { + t.Helper() + target := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(target, []byte(body), 0o755)) + return target +} + +func packageToTargetWithError(t *testing.T, info *nfpm.Info) (string, error) { + t.Helper() + target := filepath.Join(t.TempDir(), Default.ConventionalFileName(info)) + info.Target = target + f, err := os.Create(target) + require.NoError(t, err) + packageErr := Default.Package(info, f) + closeErr := f.Close() + if packageErr != nil { + return target, packageErr + } + return target, closeErr +} + +func packageToTarget(t *testing.T, info *nfpm.Info) string { + t.Helper() + target, err := packageToTargetWithError(t, info) + require.NoError(t, err) + return target +} + +func requireSignatureSidecarVerifies(t *testing.T, target, publicKey string) { + t.Helper() + packageData, err := os.ReadFile(target) + require.NoError(t, err) + digest := sha256.Sum256(packageData) + signature, err := os.ReadFile(target + ".sig2") + require.NoError(t, err) + require.NotEmpty(t, signature) + require.NoError(t, sign.RSAVerifySHA256Digest(digest[:], signature, publicKey)) +} + +func TestRegistered(t *testing.T) { + packager, err := nfpm.Get(packagerName) + require.NoError(t, err) + require.Equal(t, Default, packager) +} + +func TestConventionalExtension(t *testing.T) { + require.Equal(t, ".xbps", Default.ConventionalExtension()) +} + +func TestConventionalFileName(t *testing.T) { + info := exampleInfo() + require.Equal(t, "foo-1.2.3.beta-1.git-1_2.x86_64.xbps", Default.ConventionalFileName(info)) +} + +func TestConventionalFileNameDefaultRelease(t *testing.T) { + info := exampleInfo() + info.Release = "" + require.Equal(t, "foo-1.2.3.beta-1.git-1_1.x86_64.xbps", Default.ConventionalFileName(info)) +} + +func TestConventionalFileNameNoarch(t *testing.T) { + info := exampleInfo() + info.Arch = "all" + require.Equal(t, "foo-1.2.3.beta-1.git-1_2.noarch.xbps", Default.ConventionalFileName(info)) +} + +func TestEnsureValidArchOverride(t *testing.T) { + info := exampleInfo() + info.XBPS.Arch = "ppc64le" + normalized, err := ensureValidArch(info) + require.NoError(t, err) + require.Equal(t, "ppc64le", normalized.Arch) +} + +func TestEnsureValidArchMappings(t *testing.T) { + testCases := map[string]string{ + "all": "noarch", + "noarch": "noarch", + "amd64": "x86_64", + "x86_64": "x86_64", + "386": "i686", + "i386": "i686", + "i686": "i686", + "arm64": "aarch64", + "aarch64": "aarch64", + "arm6": "armv6l", + "arm7": "armv7l", + } + + for input, expected := range testCases { + input, expected := input, expected + t.Run(input, func(t *testing.T) { + info := exampleInfo() + info.Arch = input + normalized, err := ensureValidArch(info) + require.NoError(t, err) + require.Equal(t, expected, normalized.Arch) + }) + } +} + +func TestEnsureValidArchUnknown(t *testing.T) { + info := exampleInfo() + info.Arch = "loong64" + _, err := ensureValidArch(info) + require.ErrorContains(t, err, `unsupported architecture "loong64"`) +} + +func TestVersionNormalizesLeadingVAndParts(t *testing.T) { + info := exampleInfo() + require.Equal(t, "1.2.3.beta-1.git-1", version(info)) + + info.Prerelease = "-rc1-" + info.VersionMetadata = ".build2." + require.Equal(t, "1.2.3.rc1.build2", version(info)) +} + +func TestRevisionDefaultsToOneWhenEmpty(t *testing.T) { + info := exampleInfo() + info.Release = "" + rev, err := revision(info) + require.NoError(t, err) + require.Equal(t, "1", rev) +} + +func TestRevisionRejectsNonPositiveInteger(t *testing.T) { + for _, release := range []string{"beta1", "0", "-1"} { + release := release + t.Run(release, func(t *testing.T) { + info := exampleInfo() + info.Release = release + _, err := revision(info) + require.ErrorContains(t, err, "must be a positive integer revision") + }) + } +} + +func TestPackageRejectsNonLinux(t *testing.T) { + info := exampleInfo() + info.Platform = "windows" + + err := Default.Package(info, &bytes.Buffer{}) + require.ErrorContains(t, err, "invalid platform") +} + +func TestPackageRejectsUnknownArch(t *testing.T) { + info := exampleInfo() + info.Arch = "loong64" + + err := Default.Package(info, &bytes.Buffer{}) + require.ErrorContains(t, err, `unsupported architecture "loong64"`) +} + +func TestPackageRejectsNonPositiveRelease(t *testing.T) { + info := exampleInfo() + info.Release = "0" + + err := Default.Package(info, &bytes.Buffer{}) + require.ErrorContains(t, err, "must be a positive integer revision") +} + +func TestPackageWritesZstdArchive(t *testing.T) { + info := exampleInfo() + var buf bytes.Buffer + require.NoError(t, Default.Package(info, &buf)) + + data := buf.Bytes() + require.GreaterOrEqual(t, len(data), 4) + require.Equal(t, []byte{0x28, 0xB5, 0x2F, 0xFD}, data[:4]) + + entries := readTarEntries(t, data) + require.Contains(t, entries, "./props.plist") + require.Contains(t, entries, "./files.plist") + require.Contains(t, entries, "./usr/bin/fake") + require.Contains(t, entries, "./etc/fake/fake.conf") + require.Contains(t, entries, "./etc/fake/fake-link.conf") + require.Contains(t, entries, "./var/lib/foo/") + require.Contains(t, string(entries["./props.plist"]), "foo-1.2.3.beta-1.git-1_2") + require.Contains(t, string(entries["./props.plist"]), "Foo does things") + require.Contains(t, string(entries["./files.plist"]), "/etc/fake/fake.conf") +} + +func TestPackageControlEntriesComeFirst(t *testing.T) { + info := exampleInfo() + var buf bytes.Buffer + require.NoError(t, Default.Package(info, &buf)) + + names := readTarNames(t, buf.Bytes()) + require.GreaterOrEqual(t, len(names), 4) + require.Equal(t, []string{"./props.plist", "./files.plist"}, names[:2]) + payload := append([]string(nil), names[2:]...) + require.True(t, sort.StringsAreSorted(payload), "payload entries should be sorted after control entries: %v", payload) +} + +func TestPackageWritesLifecycleScripts(t *testing.T) { + info := exampleInfo() + dir := t.TempDir() + info.Scripts.PreInstall = writeTempScript(t, dir, "preinstall.sh", "echo preinstall\n") + info.Scripts.PostInstall = writeTempScript(t, dir, "postinstall.sh", "echo postinstall\n") + info.Scripts.PreRemove = writeTempScript(t, dir, "preremove.sh", "echo preremove\n") + info.Scripts.PostRemove = writeTempScript(t, dir, "postremove.sh", "echo postremove\n") + + var buf bytes.Buffer + require.NoError(t, Default.Package(info, &buf)) + + entries := readTarEntries(t, buf.Bytes()) + install := string(entries["./INSTALL"]) + remove := string(entries["./REMOVE"]) + + require.Contains(t, install, "#!/bin/sh") + require.Contains(t, install, "preinstall()") + require.Contains(t, install, "postinstall()") + require.Contains(t, install, "pre)") + require.Contains(t, install, "post)") + require.Contains(t, install, "echo preinstall") + require.Contains(t, install, "echo postinstall") + + require.Contains(t, remove, "#!/bin/sh") + require.Contains(t, remove, "preremove()") + require.Contains(t, remove, "postremove()") + require.Contains(t, remove, "pre)") + require.Contains(t, remove, "post)") + require.Contains(t, remove, "echo preremove") + require.Contains(t, remove, "echo postremove") +} + +func TestPackageOmitsLifecycleScriptsWhenUnset(t *testing.T) { + info := exampleInfo() + var buf bytes.Buffer + require.NoError(t, Default.Package(info, &buf)) + + entries := readTarEntries(t, buf.Bytes()) + require.NotContains(t, entries, "./INSTALL") + require.NotContains(t, entries, "./REMOVE") +} + +func TestPackageReturnsLifecycleScriptReadError(t *testing.T) { + info := exampleInfo() + info.Scripts.PreInstall = filepath.Join(t.TempDir(), "missing-preinstall.sh") + + err := Default.Package(info, &bytes.Buffer{}) + require.ErrorContains(t, err, "missing-preinstall.sh") +} + +func TestPackageDoesNotWriteSignatureSidecarWhenUnsigned(t *testing.T) { + info := exampleInfo() + target := packageToTarget(t, info) + + _, err := os.Stat(target + ".sig2") + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestPackageWritesSignatureSidecar(t *testing.T) { + info := exampleInfo() + info.XBPS.Signature.KeyFile = "../internal/sign/testdata/rsa_unprotected.priv" + + target := packageToTarget(t, info) + requireSignatureSidecarVerifies(t, target, "../internal/sign/testdata/rsa_unprotected.pub") +} + +func TestPackageWritesEncryptedSignatureSidecar(t *testing.T) { + info := exampleInfo() + info.XBPS.Signature.KeyFile = "../internal/sign/testdata/rsa.priv" + info.XBPS.Signature.KeyPassphrase = "hunter2" + + target := packageToTarget(t, info) + requireSignatureSidecarVerifies(t, target, "../internal/sign/testdata/rsa.pub") +} + +func TestPackageWritesSignatureSidecarWithInjectedSigner(t *testing.T) { + info := exampleInfo() + var signedDigest []byte + info.XBPS.Signature.SignFn = func(data io.Reader) ([]byte, error) { + var err error + signedDigest, err = io.ReadAll(data) + return []byte("injected-signature"), err + } + + target := packageToTarget(t, info) + packageData, err := os.ReadFile(target) + require.NoError(t, err) + digest := sha256.Sum256(packageData) + require.Equal(t, digest[:], signedDigest) + + sidecar, err := os.ReadFile(target + ".sig2") + require.NoError(t, err) + require.Equal(t, "injected-signature", string(sidecar)) +} + +func TestPackageRequiresTargetPathForSignatureSidecar(t *testing.T) { + info := exampleInfo() + info.XBPS.Signature.KeyFile = "../internal/sign/testdata/rsa_unprotected.priv" + + err := Default.Package(info, &bytes.Buffer{}) + var signingErr *nfpm.ErrSigningFailure + require.ErrorAs(t, err, &signingErr) + require.ErrorContains(t, err, "target path required for signature sidecar") +} + +func TestPackageReturnsSigningFailure(t *testing.T) { + invalidKey := filepath.Join(t.TempDir(), "invalid.pem") + require.NoError(t, os.WriteFile(invalidKey, []byte("not a pem"), 0o600)) + + testCases := map[string]struct { + keyFile string + wantErr string + }{ + "missing key": {filepath.Join(t.TempDir(), "missing.pem"), "reading key file"}, + "invalid key": {invalidKey, "no PEM block found"}, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + t.Run(name, func(t *testing.T) { + info := exampleInfo() + info.XBPS.Signature.KeyFile = testCase.keyFile + + _, err := packageToTargetWithError(t, info) + var signingErr *nfpm.ErrSigningFailure + require.ErrorAs(t, err, &signingErr) + require.ErrorContains(t, err, testCase.wantErr) + }) + } +} + +func TestPropsManifestUsesGenericMetadata(t *testing.T) { + info := exampleInfo() + require.NoError(t, nfpm.PrepareForPackager(info, packagerName)) + + props, err := propsManifest(info) + require.NoError(t, err) + require.Equal(t, "x86_64", props["architecture"]) + require.Equal(t, "foo", props["pkgname"]) + require.Equal(t, "foo-1.2.3.beta-1.git-1_2", props["pkgver"]) + require.Equal(t, "1.2.3.beta-1.git-1", props["version"]) + require.Equal(t, "Foo does things", props["short_desc"]) + require.Equal(t, "Foo does things\nAnd does them well.", props["long_desc"]) + require.Equal(t, "https://example.com/foo", props["homepage"]) + require.Equal(t, "MIT", props["license"]) + require.Equal(t, "Carlos A Becker ", props["maintainer"]) + require.Equal(t, plistArray{"bash", "zlib>=1.0_1"}, props["run_depends"]) + require.Equal(t, plistArray{"bar<2.0"}, props["conflicts"]) + require.Equal(t, plistArray{"foo-virtual-1.0_1"}, props["provides"]) + require.Equal(t, plistArray{"foo-old>=1.0"}, props["replaces"]) + require.Equal(t, plistArray{"/etc/fake/fake.conf"}, props["conf_files"]) +} + +func TestPropsManifestUsesXBPSMetadata(t *testing.T) { + info := exampleInfo() + info.XBPS.ShortDesc = "Explicit XBPS summary" + info.XBPS.Preserve = true + info.XBPS.Tags = []string{"utilities", "cli"} + info.XBPS.Reverts = []string{"1.2.2_1", "1.2.1_1"} + info.XBPS.Alternatives = []nfpm.XBPSAlternative{ + {Group: "editor", LinkName: "/usr/bin/view", Target: "/usr/bin/foo-view"}, + {Group: "editor", LinkName: "/usr/bin/edit", Target: "/usr/bin/foo"}, + {Group: "pager", LinkName: "/usr/bin/page", Target: "/usr/bin/foo-page"}, + } + require.NoError(t, nfpm.PrepareForPackager(info, packagerName)) + + props, err := propsManifest(info) + require.NoError(t, err) + require.Equal(t, "Explicit XBPS summary", props["short_desc"]) + require.Equal(t, true, props["preserve"]) + require.Equal(t, "cli utilities", props["tags"]) + require.Equal(t, plistArray{"1.2.1_1", "1.2.2_1"}, props["reverts"]) + require.Equal(t, plistDict{ + "editor": plistArray{"/usr/bin/edit:/usr/bin/foo", "/usr/bin/view:/usr/bin/foo-view"}, + "pager": plistArray{"/usr/bin/page:/usr/bin/foo-page"}, + }, props["alternatives"]) +} + +func TestPropsManifestRejectsMalformedAlternatives(t *testing.T) { + testCases := map[string]nfpm.XBPSAlternative{ + "empty group": {LinkName: "/usr/bin/foo", Target: "/usr/bin/foo-tool"}, + "group delimiter": {Group: "foo:bar", LinkName: "/usr/bin/foo", Target: "/usr/bin/foo-tool"}, + "group whitespace": {Group: "foo bar", LinkName: "/usr/bin/foo", Target: "/usr/bin/foo-tool"}, + "empty link": {Group: "foo", Target: "/usr/bin/foo-tool"}, + "link delimiter": {Group: "foo", LinkName: "/usr/bin/foo:alt", Target: "/usr/bin/foo-tool"}, + "empty target": {Group: "foo", LinkName: "/usr/bin/foo"}, + "target delimiter": {Group: "foo", LinkName: "/usr/bin/foo", Target: "/usr/bin/foo:tool"}, + } + + for name, alt := range testCases { + name, alt := name, alt + t.Run(name, func(t *testing.T) { + info := exampleInfo() + info.XBPS.Alternatives = []nfpm.XBPSAlternative{alt} + require.NoError(t, nfpm.PrepareForPackager(info, packagerName)) + + _, err := propsManifest(info) + require.ErrorContains(t, err, "xbps: invalid alternative") + }) + } +} + +func TestPropsManifestIncludesAllConfigVariants(t *testing.T) { + info := exampleInfo() + info.Contents = append( + info.Contents, + &files.Content{ + Source: "../testdata/whatever.conf", + Destination: "/etc/fake/a.conf", + Type: files.TypeConfigNoReplace, + }, + &files.Content{ + Source: "../testdata/whatever2.conf", + Destination: "/etc/fake/b.conf", + Type: files.TypeConfigMissingOK, + }, + ) + require.NoError(t, nfpm.PrepareForPackager(info, packagerName)) + + props, err := propsManifest(info) + require.NoError(t, err) + require.Equal(t, plistArray{"/etc/fake/a.conf", "/etc/fake/b.conf", "/etc/fake/fake.conf"}, props["conf_files"]) +} + +func TestFilesManifestClassifiesPayloadEntries(t *testing.T) { + info := exampleInfo() + require.NoError(t, nfpm.PrepareForPackager(info, packagerName)) + + manifest, err := filesManifest(info) + require.NoError(t, err) + require.Contains(t, manifest, "files") + require.Contains(t, manifest, "conf_files") + require.Contains(t, manifest, "links") + require.Contains(t, manifest, "dirs") + + links := manifest["links"].(plistArray) + require.Contains(t, links, plistDict{"file": "/etc/fake/fake-link.conf", "target": "/etc/fake/fake.conf"}) +} + +func TestMarshalPlistEscapesXMLDelimitersOnly(t *testing.T) { + data, err := marshalPlist(plistDict{"long_desc": ` & keep +quotes " ' untouched`}) + require.NoError(t, err) + text := strings.ReplaceAll(string(data), "\r\n", "\n") + require.Contains(t, text, "<tag> & keep") + require.Contains(t, text, `quotes " ' untouched`) + require.NotContains(t, text, " ") +} + +func TestSymlinkMetadataMatchesTarPayload(t *testing.T) { + // Regression test: for a relative symlink target the files.plist + // "target" must equal the value written into the tar header + // (Linkname: content.Source). Previously the metadata was normalized + // to an absolute path while the payload kept the raw relative target, + // which made xbps-pkgdb report the link as modified. + for _, src := range []string{"../lib/libfoo.so.1", "/usr/lib/libfoo.so.1", "libfoo.so.1"} { + content := &files.Content{ + Source: src, + Destination: "/usr/lib/foo/libfoo.so", + Type: files.TypeSymlink, + FileInfo: &files.ContentFileInfo{MTime: testMTime}, + } + + entry, err := fileEntry(content) + require.NoError(t, err) + require.Equal(t, src, entry["target"], "files.plist target must equal raw Source") + + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + require.NoError(t, writeContentEntry(tw, content)) + require.NoError(t, tw.Close()) + + tr := tar.NewReader(&buf) + hdr, err := tr.Next() + require.NoError(t, err) + require.Equal(t, hdr.Linkname, entry["target"], "tar Linkname and files.plist target must agree") + } +} + +func TestWritePlistValueScalarTypes(t *testing.T) { + var buf bytes.Buffer + root := plistDict{ + "flagTrue": true, + "flagFalse": false, + "count": int64(7), + "size": uint64(42), + "list": plistArray{int64(1), "two"}, + } + require.NoError(t, writePlistValue(&buf, root)) + out := buf.String() + require.Contains(t, out, "") + require.Contains(t, out, "") + require.Contains(t, out, "7") + require.Contains(t, out, "42") + require.Contains(t, out, "1two") +} + +func TestWritePlistValueUnsupportedType(t *testing.T) { + var buf bytes.Buffer + require.ErrorContains(t, writePlistValue(&buf, 3.14), "unsupported plist value type") +} + +func TestWritePlistValueErrorPropagatesThroughContainers(t *testing.T) { + var buf bytes.Buffer + require.ErrorContains(t, writePlistValue(&buf, plistArray{3.14}), "unsupported plist value type") + + buf.Reset() + require.ErrorContains(t, writePlistValue(&buf, plistDict{"bad": 3.14}), "unsupported plist value type") +} + +func TestFileEntryDirectoryOmitsChecksum(t *testing.T) { + entry, err := fileEntry(&files.Content{ + Destination: "/var/lib/foo", + Type: files.TypeDir, + }) + require.NoError(t, err) + require.Equal(t, "/var/lib/foo", entry["file"]) + require.NotContains(t, entry, "sha256") + require.NotContains(t, entry, "size") +} + +func TestFileEntryReturnsOpenError(t *testing.T) { + _, err := fileEntry(&files.Content{ + Source: filepath.Join(t.TempDir(), "missing"), + Destination: "/usr/bin/foo", + }) + require.Error(t, err) +} + +func TestWriteContentEntryDirectory(t *testing.T) { + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + require.NoError(t, writeContentEntry(tw, &files.Content{ + Destination: "/var/lib/foo", + Type: files.TypeDir, + FileInfo: &files.ContentFileInfo{MTime: testMTime}, + })) + require.NoError(t, tw.Close()) + + hdr, err := tar.NewReader(&buf).Next() + require.NoError(t, err) + require.Equal(t, byte(tar.TypeDir), hdr.Typeflag) +} + +func TestWriteContentEntryReturnsOpenError(t *testing.T) { + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + err := writeContentEntry(tw, &files.Content{ + Source: filepath.Join(t.TempDir(), "missing"), + Destination: "/usr/bin/foo", + FileInfo: &files.ContentFileInfo{MTime: testMTime}, + }) + require.Error(t, err) +}