diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3dfc349a..779b9167 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -105,6 +105,20 @@ jobs: - run: task acceptance env: TEST_PATTERN: "TestMSIXStructure" + msi-acceptance-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: stable + - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - run: task setup + - run: task acceptance + env: + TEST_PATTERN: "TestMSIStructure" windows-build-pkgs: needs: [unit-tests] runs-on: windows-latest @@ -170,3 +184,18 @@ jobs: Write-Error "Expected 'nfpm-msix-test-ok' but got '$output'" exit 1 } + msi-windows-install: + needs: [unit-tests] + runs-on: windows-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: stable + - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Build MSI package + run: task acceptance:windows:package:msi + - name: Install and verify MSI package + run: task acceptance:windows:install:msi diff --git a/Taskfile.yml b/Taskfile.yml index b49dfc0a..ab47ae12 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -67,6 +67,20 @@ tasks: cmds: - pwsh -ExecutionPolicy Bypass -File testdata/acceptance/install-msix.ps1 + acceptance:windows:package:msi: + desc: Build MSI package for Windows install testing + platforms: [windows] + cmds: + - mkdir -p ./dist + - go build -o ./dist/testapp.exe ./testdata/acceptance/testapp/ + - go run ./cmd/nfpm/... pkg -f ./testdata/acceptance/msi.install.yaml -p msi -t ./dist/foo.msi + + acceptance:windows:install:msi: + desc: Install and verify MSI package on Windows + platforms: [windows] + cmds: + - pwsh -ExecutionPolicy Bypass -File testdata/acceptance/install-msi.ps1 + acceptance:pull: desc: Pull acceptance test images vars: diff --git a/acceptance_test.go b/acceptance_test.go index 0b21515c..02ec6c85 100644 --- a/acceptance_test.go +++ b/acceptance_test.go @@ -17,9 +17,11 @@ import ( _ "github.com/goreleaser/nfpm/v2/arch" _ "github.com/goreleaser/nfpm/v2/deb" _ "github.com/goreleaser/nfpm/v2/ipk" + _ "github.com/goreleaser/nfpm/v2/msi" _ "github.com/goreleaser/nfpm/v2/msix" _ "github.com/goreleaser/nfpm/v2/rpm" "github.com/stretchr/testify/require" + gomsi "go.digitalxero.dev/go-msi" ) // nolint: gochecknoglobals @@ -473,3 +475,53 @@ func TestMSIXStructure(t *testing.T) { }) } } + +func TestMSIStructure(t *testing.T) { + t.Parallel() + for _, arch := range []string{"amd64", "arm64"} { + arch := arch + t.Run(arch, func(t *testing.T) { + t.Parallel() + + configFile := "./testdata/acceptance/msi.basic.yaml" + envFunc := func(s string) string { + switch s { + case "BUILD_ARCH": + return arch + case "SEMVER": + return "v1.0.0-0.1.b1+git.abcdefgh" + default: + return os.Getenv(s) + } + } + + config, err := nfpm.ParseFileWithEnvMapping(configFile, envFunc) + require.NoError(t, err) + + info, err := config.Get("msi") + require.NoError(t, err) + require.NoError(t, nfpm.Validate(info)) + + pkg, err := nfpm.Get("msi") + require.NoError(t, err) + + var buf bytes.Buffer + require.NoError(t, pkg.Package(nfpm.WithDefaults(info), &buf)) + + // An MSI is an OLE Compound File (CFB) container. + cfbMagic := []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1} + require.True(t, bytes.HasPrefix(buf.Bytes(), cfbMagic), "output must be a CFB container") + + // The package must pass ICE validation with no error-severity findings. + v, err := gomsi.NewValidator().WithAllICEs().Build() + require.NoError(t, err) + findings, err := v.Validate(bytes.NewReader(buf.Bytes())) + require.NoError(t, err) + for _, f := range findings { + if f.Severity() == gomsi.SeverityError { + t.Errorf("ICE error finding %s: %s", f.ICE(), f.Message()) + } + } + }) + } +} diff --git a/files/files.go b/files/files.go index ebfd81e9..bf913ab0 100644 --- a/files/files.go +++ b/files/files.go @@ -399,10 +399,14 @@ func sortedParents(dst string) []string { paths := []string{} base := strings.Trim(dst, "/") for { - base = filepath.Dir(base) - if base == "." { + parent := filepath.Dir(base) + // Stop at the filesystem root ("." for relative paths) and at volume + // roots such as Windows "C:/", where filepath.Dir stops making progress + // and would otherwise loop forever (e.g. for drive-letter destinations). + if parent == "." || parent == base { break } + base = parent paths = append(paths, ToNixPath(base)) } diff --git a/files/files_test.go b/files/files_test.go index fb4153ab..c5faba45 100644 --- a/files/files_test.go +++ b/files/files_test.go @@ -611,6 +611,34 @@ func TestImplicitDirectories(t *testing.T) { require.Equal(t, expected, withoutFileInfo(results)) } +// TestDriveLetterDestinationTerminates guards against the parent-directory walk +// looping forever on a Windows drive-letter destination (filepath.Dir gets +// stuck at the volume root), which previously caused an out-of-memory crash. +func TestDriveLetterDestinationTerminates(t *testing.T) { + results, err := files.PrepareForPackager( + files.Contents{ + { + Source: "./testdata/globtest/a.txt", + Destination: "C:/Program Files/App/a.txt", + }, + }, + 0, + "", + false, + mtime, + ) + require.NoError(t, err) + + var file *files.Content + for _, c := range results { + if c.Type == files.TypeFile { + file = c + } + } + require.NotNil(t, file) + require.Equal(t, "testdata/globtest/a.txt", file.Source) +} + func TestRelevantFiles(t *testing.T) { contents := files.Contents{ { diff --git a/go.mod b/go.mod index b171f184..0163ac08 100644 --- a/go.mod +++ b/go.mod @@ -21,8 +21,10 @@ require ( github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/ulikunitz/xz v0.5.15 + go.digitalxero.dev/go-msi v0.2.0 go.digitalxero.dev/go-msix v0.3.1 go.yaml.in/yaml/v3 v3.0.4 + software.sslmate.com/src/go-pkcs12 v0.7.1 ) require ( @@ -32,6 +34,7 @@ require ( github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect + github.com/abemedia/go-cfb v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/cavaliergopher/cpio v1.0.1 // indirect @@ -94,5 +97,4 @@ require ( golang.org/x/text v0.37.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - software.sslmate.com/src/go-pkcs12 v0.7.1 // indirect ) diff --git a/go.sum b/go.sum index f6810a2f..ee3699a4 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ek github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw= github.com/ProtonMail/gopenpgp/v2 v2.7.1 h1:Awsg7MPc2gD3I7IFac2qE3Gdls0lZW8SzrFZ3k1oz0s= github.com/ProtonMail/gopenpgp/v2 v2.7.1/go.mod h1:/BU5gfAVwqyd8EfC3Eu7zmuhwYQpKs+cGD8M//iiaxs= +github.com/abemedia/go-cfb v0.2.0 h1:amLvu7/DX65XtcmMuc0an/t09W36gMOrnYwLxGGmwX8= +github.com/abemedia/go-cfb v0.2.0/go.mod h1:ytSRuNKf2VMDQDs8TSZ/VmkVfS4giAdc4xl5XOnpKKA= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= @@ -205,6 +207,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= gitlab.com/digitalxero/go-conventional-commit v1.0.7 h1:8/dO6WWG+98PMhlZowt/YjuiKhqhGlOCwlIV8SqqGh8= gitlab.com/digitalxero/go-conventional-commit v1.0.7/go.mod h1:05Xc2BFsSyC5tKhK0y+P3bs0AwUtNuTp+mTpbCU/DZ0= +go.digitalxero.dev/go-msi v0.2.0 h1:ch1gOlRkwv+AvqmbEmXI0zcZSFGOSbP13qbRn41k720= +go.digitalxero.dev/go-msi v0.2.0/go.mod h1:KmoYF1YR3JjpaZmRmiZunTpQ4fKJsnYGgFmTN6gqlgE= go.digitalxero.dev/go-msix v0.3.1 h1:V5E8PuFkA3Fr3VFYX6pTUutriogYC9sgxIWhzf9sSKw= go.digitalxero.dev/go-msix v0.3.1/go.mod h1:QbUpFs0AUd1zk7e9fy17suiqEAF90TR3jZY+LCI2K+c= go.mozilla.org/pkcs7 v0.9.0 h1:yM4/HS9dYv7ri2biPtxt8ikvB37a980dg69/pKmS+eI= diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 63019fc2..0165edad 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -10,6 +10,7 @@ import ( _ "github.com/goreleaser/nfpm/v2/arch" // archlinux packager _ "github.com/goreleaser/nfpm/v2/deb" // deb packager _ "github.com/goreleaser/nfpm/v2/ipk" // ipk packager + _ "github.com/goreleaser/nfpm/v2/msi" // msi packager _ "github.com/goreleaser/nfpm/v2/msix" // msix packager _ "github.com/goreleaser/nfpm/v2/rpm" // rpm packager "github.com/spf13/cobra" diff --git a/msi/msi.go b/msi/msi.go new file mode 100644 index 00000000..2cced315 --- /dev/null +++ b/msi/msi.go @@ -0,0 +1,623 @@ +// Package msi implements nfpm.Packager providing real Windows Installer (.msi) +// builds via the pure-Go go.digitalxero.dev/go-msi library. +package msi + +import ( + "crypto/sha1" + "errors" + "fmt" + "hash/fnv" + "io" + "io/fs" + "log" + "os" + "path" + "regexp" + "strconv" + "strings" + + "github.com/goreleaser/nfpm/v2" + "github.com/goreleaser/nfpm/v2/files" + "go.digitalxero.dev/go-msi" +) + +const packagerName = "msi" + +// mainFeature is the single primary feature every installed component is +// associated with. +const mainFeature = "MainFeature" + +// nolint: gochecknoinits +func init() { + nfpm.RegisterPackager(packagerName, Default) +} + +// Default msi packager. +// nolint: gochecknoglobals +var Default = &MSI{} + +// MSI is an msi packager implementation. +type MSI struct{} + +// nolint: gochecknoglobals +var archToMSI = map[string]string{ + "amd64": "x64", + "x86_64": "x64", + "386": "x86", + "i386": "x86", + "i686": "x86", + "arm64": "arm64", + "aarch64": "arm64", + "arm": "arm", + "arm7": "arm", + "all": "neutral", +} + +func ensureValidArch(info *nfpm.Info) *nfpm.Info { + if info.MSI.Arch != "" { + info.Arch = info.MSI.Arch + } else if arch, ok := archToMSI[info.Arch]; ok { + info.Arch = arch + } + return info +} + +// is64bit reports whether the (already MSI-normalized) architecture is 64-bit. +func is64bit(arch string) bool { + switch arch { + case "x64", "arm64": + return true + default: + return false + } +} + +// ConventionalFileName returns the conventional file name for an MSI package. +func (m *MSI) ConventionalFileName(info *nfpm.Info) string { + info = ensureValidArch(info) + version := convertToMSIVersion(info.Version) + return fmt.Sprintf("%s_%s_%s.msi", info.Name, version, info.Arch) +} + +// ConventionalExtension returns the file extension for MSI packages. +func (*MSI) ConventionalExtension() string { + return ".msi" +} + +// SetPackagerDefaults sets default values for MSI-specific fields. +func (*MSI) SetPackagerDefaults(info *nfpm.Info) { + if info.MSI.ProductName == "" { + info.MSI.ProductName = info.Name + } + if info.MSI.InstallDir == "" { + info.MSI.InstallDir = info.MSI.ProductName + } + if info.MSI.AllUsers == nil { + allUsers := true + info.MSI.AllUsers = &allUsers + } + for i := range info.MSI.Services { + if info.MSI.Services[i].StartType == "" { + info.MSI.Services[i].StartType = "demand" + } + } + for i := range info.MSI.Shortcuts { + if info.MSI.Shortcuts[i].Directory == "" { + info.MSI.Shortcuts[i].Directory = "ProgramMenuFolder" + } + } +} + +// Package writes a new MSI package to the given writer using the given info. +func (m *MSI) Package(info *nfpm.Info, w io.Writer) error { + m.SetPackagerDefaults(info) + info = ensureValidArch(info) + + if err := nfpm.PrepareForPackager(info, packagerName); err != nil { + return err + } + + if err := validate(info); err != nil { + return err + } + + b := msi.NewPackage(). + WithProductName(info.MSI.ProductName). + WithManufacturer(info.MSI.Manufacturer). + WithVersion(convertToMSIVersion(info.Version)). + WithAllUsers(*info.MSI.AllUsers) + + // ProductCode must always be present. When omitted we derive a stable GUID + // from the product name (kept constant across versions so it does not change + // on every version bump). + // go-msi requires braced uppercase GUIDs; validation accepts either case, so + // normalize the user-provided value here. + productCode := strings.ToUpper(info.MSI.ProductCode) + if productCode == "" { + productCode = deriveGUID("product|" + info.MSI.ProductName) + } + b = b.WithProductCode(productCode) + + // UpgradeCode stays stable across versions; derive it from the product name + // alone when omitted so upgrades work out of the box. + upgradeCode := strings.ToUpper(info.MSI.UpgradeCode) + if upgradeCode == "" { + upgradeCode = deriveGUID("upgrade|" + info.MSI.ProductName) + } + b = b.WithUpgradeCode(upgradeCode) + for k, v := range info.MSI.Properties { + b = b.WithProperty(k, v) + } + + // Declare INSTALLFOLDER explicitly so its DefaultDir is the configured + // install directory name (otherwise go-msi derives it from the product name). + b.RootDirectory("INSTALLFOLDER", info.MSI.InstallDir) + + // The single primary feature every component is associated with. + b.Feature(mainFeature).WithTitle(info.MSI.ProductName).WithLevel(1) + + placed, err := addContents(b, info) + if err != nil { + return err + } + + if err := addShortcuts(b, info, placed); err != nil { + return err + } + if err := addServices(b, info, placed); err != nil { + return err + } + addRegistry(b, info) + + if info.MSI.Upgrade.Enabled { + mu := b.MajorUpgrade() + if info.MSI.Upgrade.DowngradeErrorMessage != "" { + mu.DowngradeErrorMessage(info.MSI.Upgrade.DowngradeErrorMessage) + } + } + + if info.MSI.MinimalUI { + b.WithMinimalUI() + } + if info.MSI.License != "" { + text, err := os.ReadFile(info.MSI.License) + if err != nil { + return fmt.Errorf("reading license file %s: %w", info.MSI.License, err) + } + b.WithLicenseText(string(text)) + } + + if info.MSI.Signature.PFXFile != "" { + if err := configureSigning(b, info); err != nil { + return err + } + } + + pkg, err := b.Build() + if err != nil { + return err + } + + return pkg.WriteMSI(w) +} + +// placement records where a content file was installed so shortcuts and services +// can reference it by its original destination path. +type placement struct { + componentID string + rootID string +} + +func validate(info *nfpm.Info) error { + if info.MSI.Manufacturer == "" { + return fmt.Errorf("package %s must be provided", "msi.manufacturer") + } + if info.MSI.ProductCode != "" && !looksLikeGUID(info.MSI.ProductCode) { + return fmt.Errorf("package msi.product_code %q must be a braced GUID", info.MSI.ProductCode) + } + if info.MSI.UpgradeCode != "" && !looksLikeGUID(info.MSI.UpgradeCode) { + return fmt.Errorf("package msi.upgrade_code %q must be a braced GUID", info.MSI.UpgradeCode) + } + + dests := map[string]bool{} + for _, c := range info.Contents { + if c.Type == files.TypeDir || c.Type == files.TypeImplicitDir || c.Type == files.TypeSymlink { + continue + } + dests[normalizeDest(c.Destination)] = true + } + + for i, s := range info.MSI.Shortcuts { + if s.Name == "" { + return fmt.Errorf("package msi.shortcuts[%d].name must be provided", i) + } + if s.Target == "" { + return fmt.Errorf("package msi.shortcuts[%d].target must be provided", i) + } + if !dests[normalizeDest(s.Target)] { + return fmt.Errorf("package msi.shortcuts[%d].target %q does not match any contents destination", i, s.Target) + } + } + for i, s := range info.MSI.Services { + if s.Name == "" { + return fmt.Errorf("package msi.services[%d].name must be provided", i) + } + if s.Executable == "" { + return fmt.Errorf("package msi.services[%d].executable must be provided", i) + } + if !dests[normalizeDest(s.Executable)] { + return fmt.Errorf("package msi.services[%d].executable %q does not match any contents destination", i, s.Executable) + } + if _, ok := startTypes[strings.ToLower(s.StartType)]; !ok { + return fmt.Errorf("package msi.services[%d].start_type %q is invalid", i, s.StartType) + } + } + for i, r := range info.MSI.Registry { + if _, ok := registryRoots[strings.ToUpper(r.Root)]; !ok { + return fmt.Errorf("package msi.registry[%d].root %q is invalid", i, r.Root) + } + if r.Key == "" { + return fmt.Errorf("package msi.registry[%d].key must be provided", i) + } + } + + return nil +} + +// addContents maps every content file to a directory/component/file in the MSI, +// honoring well-known Windows destination prefixes. Returns a placement map +// keyed by normalized destination path. +func addContents(b msi.PackageBuilder, info *nfpm.Info) (map[string]placement, error) { + placed := map[string]placement{} + createdDirs := map[string]bool{"INSTALLFOLDER": true} + + for _, content := range info.Contents { + switch content.Type { + case files.TypeDir, files.TypeImplicitDir: + // Directories are implicit in the MSI directory tree. + continue + case files.TypeSymlink: + log.Printf("warning: msi does not support symlinks, skipping %s", content.Destination) + continue + } + if content.Source == "" { + continue + } + + src, err := msi.FileSourceFromPath(content.Source) + if err != nil { + return nil, fmt.Errorf("reading file %s: %w", content.Source, err) + } + + dest := normalizeDest(content.Destination) + rootID, rootDefault, rel := mapDestination(dest, is64bit(info.Arch)) + + // Ensure the root directory exists. Standard Windows Installer folders + // (ProgramFiles64Folder, etc.) must be rooted at TARGETDIR; declaring + // them as bare roots would leave them unparented and break source + // resolution (error 2704). INSTALLFOLDER is pre-declared in Package and + // reparented by go-msi. + if !createdDirs[rootID] { + b.Directory("TARGETDIR").Subdirectory(rootID, rootDefault) + createdDirs[rootID] = true + } + + segments := strings.Split(rel, "/") + fileName := segments[len(segments)-1] + dirSegments := segments[:len(segments)-1] + + // Build the subdirectory chain, caching by accumulated path. + parentID := rootID + accum := rootID + for _, seg := range dirSegments { + if seg == "" { + continue + } + accum = accum + "/" + seg + dirID := makeID("d", accum) + if !createdDirs[dirID] { + b.Directory(parentID).Subdirectory(dirID, seg) + createdDirs[dirID] = true + } + parentID = dirID + } + + compID := makeID("c", dest) + comp := b.Directory(parentID).Component(compID).WithGUID("") + if attrs := componentAttributes(rootID, is64bit(info.Arch)); attrs != 0 { + comp = comp.WithAttributes(attrs) + } + comp.WithFile(fileName, src) + comp.AssociateToFeature(mainFeature) + + placed[dest] = placement{componentID: compID, rootID: rootID} + } + + return placed, nil +} + +func addShortcuts(b msi.PackageBuilder, info *nfpm.Info, placed map[string]placement) error { + for _, s := range info.MSI.Shortcuts { + p, ok := placed[normalizeDest(s.Target)] + if !ok { + return fmt.Errorf("shortcut %q target %q was not installed", s.Name, s.Target) + } + sc := b.Directory(p.rootID).Component(p.componentID). + Shortcut(s.Name, ""). + Advertised(mainFeature). + InDirectory(s.Directory) + if s.Description != "" { + sc = sc.Description(s.Description) + } + if s.Arguments != "" { + sc = sc.Arguments(s.Arguments) + } + if s.Icon != "" { + iconSrc, err := msi.FileSourceFromPath(s.Icon) + if err != nil { + return fmt.Errorf("reading shortcut icon %s: %w", s.Icon, err) + } + iconName := makeID("ico", s.Icon) + path.Ext(s.Icon) + b.Icon(iconName, iconSrc) + sc.Icon(iconName, 0) + } + } + return nil +} + +func addServices(b msi.PackageBuilder, info *nfpm.Info, placed map[string]placement) error { + for _, s := range info.MSI.Services { + p, ok := placed[normalizeDest(s.Executable)] + if !ok { + return fmt.Errorf("service %q executable %q was not installed", s.Name, s.Executable) + } + comp := b.Directory(p.rootID).Component(p.componentID) + + // The builder mutates in place and returns itself, so the return values + // of the chained setters are intentionally not captured. + si := comp.ServiceInstall(s.Name) + si.WithType(msi.ServiceTypeOwnProcess) + si.WithStartType(startTypes[strings.ToLower(s.StartType)]) + si.WithErrorControl(msi.ServiceErrorNormal) + if s.DisplayName != "" { + si.WithDisplayName(s.DisplayName) + } + if s.Description != "" { + si.WithDescription(s.Description) + } + if s.Account != "" { + si.WithStartName(s.Account) + } + if s.Arguments != "" { + si.WithArguments(s.Arguments) + } + if len(s.Dependencies) > 0 { + si.WithDependencies(s.Dependencies...) + } + + if s.Start || s.Stop { + sc := comp.ServiceControl(s.Name) + if s.Start { + sc.OnInstall().Start() + } + if s.Stop { + sc.OnUninstall().Stop().Delete() + } + } + } + return nil +} + +func addRegistry(b msi.PackageBuilder, info *nfpm.Info) { + for i, r := range info.MSI.Registry { + compID := makeID("reg", strconv.Itoa(i)+"|"+r.Root+"|"+r.Key+"|"+r.Name) + comp := b.Directory("INSTALLFOLDER").Component(compID).WithGUID("") + comp.RegistryKey(registryRoots[strings.ToUpper(r.Root)], r.Key). + Value(r.Name, r.Value). + AsKeyPath() + comp.AssociateToFeature(mainFeature) + } +} + +func configureSigning(b msi.PackageBuilder, info *nfpm.Info) error { + pfxPath := info.MSI.Signature.PFXFile + if _, err := os.Stat(pfxPath); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("PFX file not found: %w", err) + } + return fmt.Errorf("unable to access PFX file: %w", err) + } + + sb := msi.NewSigner().WithPFX(pfxPath, info.MSI.Signature.KeyPassphrase) + if info.MSI.Signature.TimestampURL != "" { + sb = sb.WithTimestampURL(info.MSI.Signature.TimestampURL) + } + signer, err := sb.Build() + if err != nil { + return &nfpm.ErrSigningFailure{Err: fmt.Errorf("building signer: %w", err)} + } + + b.WithSigner(signer) + return nil +} + +// nolint: gochecknoglobals +var startTypes = map[string]msi.ServiceStartType{ + "auto": msi.ServiceStartAuto, + "demand": msi.ServiceStartDemand, + "disabled": msi.ServiceStartDisabled, + "boot": msi.ServiceStartBoot, + "system": msi.ServiceStartSystem, +} + +// nolint: gochecknoglobals +var registryRoots = map[string]msi.RegistryRoot{ + "HKLM": msi.RegistryRootHKLM, + "HKCU": msi.RegistryRootHKCU, + "HKCR": msi.RegistryRootHKCR, + "HKMU": msi.RegistryRootHKMU, + "HKU": msi.RegistryRootHKU, +} + +// destPrefix maps a leading destination path (lowercased, slash-separated) to a +// well-known MSI directory ID. Longer prefixes are matched first. +// nolint: gochecknoglobals +var destPrefixes = []struct { + prefix string + dir64 string + dir32 string +}{ + {"program files (x86)", "ProgramFilesFolder", "ProgramFilesFolder"}, + {"program files", "ProgramFiles64Folder", "ProgramFilesFolder"}, + {"programdata", "CommonAppDataFolder", "CommonAppDataFolder"}, + {"windows/system32", "System64Folder", "SystemFolder"}, + {"windows/syswow64", "SystemFolder", "SystemFolder"}, + {"windows/fonts", "FontsFolder", "FontsFolder"}, + {"windows", "WindowsFolder", "WindowsFolder"}, + {"appdata/local", "LocalAppDataFolder", "LocalAppDataFolder"}, + {"appdata/roaming", "AppDataFolder", "AppDataFolder"}, +} + +// systemDirs are directories whose components should be marked Permanent to +// satisfy ICE09. +// nolint: gochecknoglobals +var systemDirs = map[string]bool{ + "SystemFolder": true, + "System64Folder": true, + "WindowsFolder": true, + "FontsFolder": true, +} + +const ( + msidbComponentAttributes64bit int16 = 0x100 + msidbComponentAttributesPermanent int16 = 0x10 +) + +func componentAttributes(rootID string, is64 bool) int16 { + var attrs int16 + if is64 { + attrs |= msidbComponentAttributes64bit + } + if systemDirs[rootID] { + attrs |= msidbComponentAttributesPermanent + } + return attrs +} + +// normalizeDest converts a destination path to a forward-slash relative path +// with any drive letter and leading slashes removed. +func normalizeDest(p string) string { + p = strings.ReplaceAll(p, "\\", "/") + // Remove leading slashes (nfpm normalizes absolute paths to start with "/", + // e.g. "C:/x" becomes "/C:/x"). + p = strings.TrimLeft(p, "/") + // Strip a leading drive letter (e.g. "C:"). + if len(p) >= 2 && p[1] == ':' { + p = p[2:] + } + p = strings.TrimLeft(p, "/") + // Collapse any duplicate slashes. + for strings.Contains(p, "//") { + p = strings.ReplaceAll(p, "//", "/") + } + return p +} + +// mapDestination resolves a normalized destination to a root directory ID, its +// DefaultDir value, and the path relative to that root (always ending in the +// file name). +func mapDestination(dest string, is64 bool) (rootID, rootDefault, rel string) { + lower := strings.ToLower(dest) + for _, p := range destPrefixes { + if lower == p.prefix || strings.HasPrefix(lower, p.prefix+"/") { + id := p.dir32 + if is64 { + id = p.dir64 + } + rest := strings.TrimPrefix(dest[len(p.prefix):], "/") + if rest == "" { + rest = path.Base(dest) + } + // Standard folders use "." as their DefaultDir. + return id, ".", rest + } + } + // Fallback: install under INSTALLFOLDER using the full relative path. + return "INSTALLFOLDER", "", dest +} + +// makeID builds a stable, MSI-valid identifier from a prefix and a seed string. +func makeID(prefix, seed string) string { + h := fnv.New32a() + _, _ = io.WriteString(h, seed) + + readable := sanitizeID(path.Base(strings.TrimRight(seed, "/"))) + if len(readable) > 40 { + readable = readable[:40] + } + return fmt.Sprintf("%s_%s_%08x", prefix, readable, h.Sum32()) +} + +// sanitizeID replaces characters that are invalid in MSI identifiers. +func sanitizeID(s string) string { + var sb strings.Builder + for _, r := range s { + switch { + case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_', r == '.': + sb.WriteRune(r) + default: + sb.WriteRune('_') + } + } + return sb.String() +} + +// guidPattern matches a canonical braced GUID ({8-4-4-4-12} hexadecimal), +// accepting either letter case. +var guidPattern = regexp.MustCompile( + `^\{[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}$`) + +func looksLikeGUID(s string) bool { + return guidPattern.MatchString(s) +} + +// deriveGUID produces a stable, braced uppercase GUID (RFC 4122 v5 style) from +// the given seed. The same seed always yields the same GUID, keeping builds +// reproducible. +func deriveGUID(seed string) string { + h := sha1.Sum([]byte("nfpm-msi:" + seed)) + var b [16]byte + copy(b[:], h[:16]) + b[6] = (b[6] & 0x0f) | 0x50 // version 5 + b[8] = (b[8] & 0x3f) | 0x80 // RFC 4122 variant + s := fmt.Sprintf("%X", b[:]) + return fmt.Sprintf("{%s-%s-%s-%s-%s}", s[0:8], s[8:12], s[12:16], s[16:20], s[20:32]) +} + +// convertToMSIVersion converts a semver-style version to MSI's +// Major.Minor.Build format. Each field is numeric and clamped to 65535. +func convertToMSIVersion(version string) string { + version = strings.TrimPrefix(version, "v") + // Drop any pre-release / build metadata. + if i := strings.IndexAny(version, "-+"); i >= 0 { + version = version[:i] + } + + parts := strings.SplitN(version, ".", 4) + result := make([]string, 3) + for i := range 3 { + result[i] = "0" + if i < len(parts) { + if n, err := strconv.Atoi(parts[i]); err == nil { + if n > 65535 { + n = 65535 + } + if n < 0 { + n = 0 + } + result[i] = strconv.Itoa(n) + } + } + } + return strings.Join(result, ".") +} diff --git a/msi/msi_test.go b/msi/msi_test.go new file mode 100644 index 00000000..032713fe --- /dev/null +++ b/msi/msi_test.go @@ -0,0 +1,403 @@ +package msi_test + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "os" + "path/filepath" + "regexp" + "testing" + "time" + + "github.com/goreleaser/nfpm/v2" + "github.com/goreleaser/nfpm/v2/files" + "github.com/goreleaser/nfpm/v2/msi" + gomsi "go.digitalxero.dev/go-msi" + pkcs12 "software.sslmate.com/src/go-pkcs12" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// cfbMagic is the OLE Compound File header shared by .msi files. +// nolint: gochecknoglobals +var cfbMagic = []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1} + +func exampleInfo() *nfpm.Info { + return nfpm.WithDefaults(&nfpm.Info{ + Name: "TestApp", + Arch: "amd64", + Description: "Test application", + Version: "v1.2.3", + Maintainer: "Test ", + Vendor: "TestCo", + Homepage: "https://example.com", + Overridables: nfpm.Overridables{ + Contents: []*files.Content{ + { + Source: "../testdata/fake", + Destination: "/Program Files/TestApp/app.exe", + }, + { + Source: "../testdata/whatever.conf", + Destination: "/app/config.conf", + }, + }, + MSI: nfpm.MSI{ + Manufacturer: "Test Company", + }, + }, + }) +} + +func TestConventionalExtension(t *testing.T) { + require.Equal(t, ".msi", msi.Default.ConventionalExtension()) +} + +func TestConventionalFileName(t *testing.T) { + // Exercises both arch mapping (amd64 -> x64) and version conversion + // (v1.2.3 -> 1.2.3). + require.Equal(t, "TestApp_1.2.3_x64.msi", msi.Default.ConventionalFileName(exampleInfo())) +} + +func TestArchMapping(t *testing.T) { + tests := map[string]string{ + "amd64": "x64", + "x86_64": "x64", + "386": "x86", + "i386": "x86", + "arm64": "arm64", + "aarch64": "arm64", + "arm": "arm", + "all": "neutral", + } + for in, want := range tests { + t.Run(in, func(t *testing.T) { + info := exampleInfo() + info.Arch = in + name := msi.Default.ConventionalFileName(info) + require.Contains(t, name, "_"+want+".msi") + }) + } +} + +func TestArchOverride(t *testing.T) { + info := exampleInfo() + info.Arch = "amd64" + info.MSI.Arch = "x86" + require.Contains(t, msi.Default.ConventionalFileName(info), "_x86.msi") +} + +func TestVersionConversion(t *testing.T) { + tests := map[string]string{ + "1.2.3": "1.2.3", + "v1.2.3": "1.2.3", + "1.0.0": "1.0.0", + "2.5": "2.5.0", + "1": "1.0.0", + "1.2.3.4": "1.2.3", + "v1.0.0-0.1.b1+git.abcd": "1.0.0", + } + for in, want := range tests { + t.Run(in, func(t *testing.T) { + info := exampleInfo() + info.Version = in + require.Equal(t, "TestApp_"+want+"_x64.msi", msi.Default.ConventionalFileName(info)) + }) + } +} + +// packageAndValidate builds an MSI and asserts it is a structurally valid, +// ICE-clean Windows Installer database. +func packageAndValidate(t *testing.T, info *nfpm.Info) { + t.Helper() + var buf bytes.Buffer + require.NoError(t, msi.Default.Package(info, &buf)) + require.Positive(t, buf.Len(), "package should not be empty") + require.True(t, bytes.HasPrefix(buf.Bytes(), cfbMagic), "output should be a CFB container") + + v, err := gomsi.NewValidator().WithAllICEs().Build() + require.NoError(t, err) + findings, err := v.Validate(bytes.NewReader(buf.Bytes())) + require.NoError(t, err) + for _, f := range findings { + if f.Severity() == gomsi.SeverityError { + t.Errorf("ICE error finding %s: %s", f.ICE(), f.Message()) + } + } +} + +func TestPackageMinimal(t *testing.T) { + packageAndValidate(t, exampleInfo()) +} + +func TestPackageWithContents(t *testing.T) { + info := exampleInfo() + info.Contents = append(info.Contents, + &files.Content{Source: "../testdata/whatever.conf", Destination: "/Program Files/TestApp/sub/extra.txt"}, + &files.Content{Source: "../testdata/whatever.conf", Destination: "relative/path/file.txt"}, + ) + packageAndValidate(t, info) +} + +func TestNoManufacturer(t *testing.T) { + info := exampleInfo() + info.MSI.Manufacturer = "" + var buf bytes.Buffer + err := msi.Default.Package(info, &buf) + require.Error(t, err) + require.Contains(t, err.Error(), "msi.manufacturer") +} + +func TestInvalidProductCode(t *testing.T) { + for _, code := range []string{ + "not-a-guid", // no braces + "{not-a-guid}", // braced but not hex {8-4-4-4-12} + "{12345678-1234-1234-1234-123456789AB}", // node too short + "{12345678-1234-1234-1234-123456789ABCD}", // node too long + "{12345678-1234-1234-1234-123456789ABG}", // non-hex digit + "12345678-1234-1234-1234-123456789ABC", // missing braces + } { + t.Run(code, func(t *testing.T) { + info := exampleInfo() + info.MSI.ProductCode = code + var buf bytes.Buffer + err := msi.Default.Package(info, &buf) + require.Error(t, err) + require.Contains(t, err.Error(), "product_code") + }) + } +} + +func TestExplicitGUIDs(t *testing.T) { + info := exampleInfo() + info.MSI.ProductCode = "{12345678-1234-1234-1234-123456789ABC}" + info.MSI.UpgradeCode = "{ABCDEF01-2345-6789-ABCD-EF0123456789}" + packageAndValidate(t, info) +} + +// TestLowercaseGUIDs proves that a canonical but lowercase GUID is accepted: +// validation is case-insensitive and the codes are normalized to uppercase +// before go-msi (which requires uppercase) sees them. +func TestLowercaseGUIDs(t *testing.T) { + info := exampleInfo() + info.MSI.ProductCode = "{12345678-1234-1234-1234-123456789abc}" + info.MSI.UpgradeCode = "{abcdef01-2345-6789-abcd-ef0123456789}" + packageAndValidate(t, info) +} + +// TestDerivedProductCode guards against shipping an MSI without a ProductCode +// (msiexec fails such installs with error 1605). When the config omits the +// codes, a derived braced GUID must be written into the package. +func TestDerivedProductCode(t *testing.T) { + info := exampleInfo() + require.Empty(t, info.MSI.ProductCode) + require.Empty(t, info.MSI.UpgradeCode) + + var buf bytes.Buffer + require.NoError(t, msi.Default.Package(info, &buf)) + + guid := regexp.MustCompile(`\{[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}\}`) + require.True(t, guid.Match(buf.Bytes()), "a derived GUID must be present in the package") + + // Derivation must be reproducible. + var buf2 bytes.Buffer + require.NoError(t, msi.Default.Package(exampleInfo(), &buf2)) + require.Equal(t, buf.Bytes(), buf2.Bytes(), "builds with identical input must be reproducible") +} + +func TestShortcut(t *testing.T) { + info := exampleInfo() + info.MSI.Shortcuts = []nfpm.MSIShortcut{ + { + Name: "Test App", + Target: "/Program Files/TestApp/app.exe", + Description: "Launch Test App", + }, + } + packageAndValidate(t, info) +} + +func TestShortcutTargetNotInContents(t *testing.T) { + info := exampleInfo() + info.MSI.Shortcuts = []nfpm.MSIShortcut{ + {Name: "Test App", Target: "/Program Files/TestApp/missing.exe"}, + } + var buf bytes.Buffer + err := msi.Default.Package(info, &buf) + require.Error(t, err) + require.Contains(t, err.Error(), "does not match any contents destination") +} + +func TestService(t *testing.T) { + info := exampleInfo() + info.Contents = append(info.Contents, &files.Content{ + Source: "../testdata/fake", + Destination: "/Program Files/TestApp/svc.exe", + }) + info.MSI.Services = []nfpm.MSIService{ + { + Name: "TestSvc", + DisplayName: "Test Service", + Executable: "/Program Files/TestApp/svc.exe", + Description: "A test service", + StartType: "auto", + Start: true, + Stop: true, + }, + } + packageAndValidate(t, info) +} + +func TestServiceTargetNotInContents(t *testing.T) { + info := exampleInfo() + info.MSI.Services = []nfpm.MSIService{ + {Name: "TestSvc", Executable: "/Program Files/TestApp/missing.exe", StartType: "demand"}, + } + var buf bytes.Buffer + err := msi.Default.Package(info, &buf) + require.Error(t, err) + require.Contains(t, err.Error(), "does not match any contents destination") +} + +func TestServiceInvalidStartType(t *testing.T) { + info := exampleInfo() + info.MSI.Services = []nfpm.MSIService{ + {Name: "TestSvc", Executable: "/Program Files/TestApp/app.exe", StartType: "bogus"}, + } + var buf bytes.Buffer + err := msi.Default.Package(info, &buf) + require.Error(t, err) + require.Contains(t, err.Error(), "start_type") +} + +func TestRegistry(t *testing.T) { + info := exampleInfo() + info.MSI.Registry = []nfpm.MSIRegistry{ + {Root: "HKLM", Key: `Software\TestCo\TestApp`, Name: "InstallPath", Value: "C:\\TestApp"}, + {Root: "HKCU", Key: `Software\TestCo\TestApp`, Name: "Enabled", Value: "1"}, + } + packageAndValidate(t, info) +} + +func TestRegistryInvalidRoot(t *testing.T) { + info := exampleInfo() + info.MSI.Registry = []nfpm.MSIRegistry{ + {Root: "HKXX", Key: `Software\TestCo`, Name: "x", Value: "y"}, + } + var buf bytes.Buffer + err := msi.Default.Package(info, &buf) + require.Error(t, err) + require.Contains(t, err.Error(), "root") +} + +func TestMajorUpgrade(t *testing.T) { + info := exampleInfo() + info.MSI.UpgradeCode = "{ABCDEF01-2345-6789-ABCD-EF0123456789}" + info.MSI.Upgrade = nfpm.MSIUpgrade{ + Enabled: true, + DowngradeErrorMessage: "A newer version is already installed.", + } + packageAndValidate(t, info) +} + +func TestMinimalUIWithLicense(t *testing.T) { + dir := t.TempDir() + license := filepath.Join(dir, "LICENSE.txt") + require.NoError(t, os.WriteFile(license, []byte("Test license text"), 0o600)) + + info := exampleInfo() + info.MSI.MinimalUI = true + info.MSI.License = license + packageAndValidate(t, info) +} + +func TestMissingLicenseFile(t *testing.T) { + info := exampleInfo() + info.MSI.License = "/does/not/exist.txt" + var buf bytes.Buffer + err := msi.Default.Package(info, &buf) + require.Error(t, err) + require.Contains(t, err.Error(), "license") +} + +func TestSetPackagerDefaults(t *testing.T) { + info := &nfpm.Info{ + Name: "MyApp", + Overridables: nfpm.Overridables{ + MSI: nfpm.MSI{ + Manufacturer: "Co", + Services: []nfpm.MSIService{{Name: "S", Executable: "x"}}, + Shortcuts: []nfpm.MSIShortcut{{Name: "S", Target: "x"}}, + }, + }, + } + msi.Default.SetPackagerDefaults(info) + + require.Equal(t, "MyApp", info.MSI.ProductName) + require.Equal(t, "MyApp", info.MSI.InstallDir) + require.NotNil(t, info.MSI.AllUsers) + require.True(t, *info.MSI.AllUsers) + require.Equal(t, "demand", info.MSI.Services[0].StartType) + require.Equal(t, "ProgramMenuFolder", info.MSI.Shortcuts[0].Directory) +} + +func TestSigning(t *testing.T) { + pfxPath, passphrase := makeTestPFX(t) + + info := exampleInfo() + info.MSI.Signature = nfpm.MSISignature{ + PFXFile: pfxPath, + KeyPassphrase: passphrase, + } + + var buf bytes.Buffer + require.NoError(t, msi.Default.Package(info, &buf)) + require.True(t, bytes.HasPrefix(buf.Bytes(), cfbMagic)) + + _, err := gomsi.Verify(bytes.NewReader(buf.Bytes())) + require.NoError(t, err, "signed MSI should verify") +} + +func TestSigningMissingPFX(t *testing.T) { + info := exampleInfo() + info.MSI.Signature.PFXFile = "/does/not/exist.pfx" + var buf bytes.Buffer + err := msi.Default.Package(info, &buf) + require.Error(t, err) + assert.Contains(t, err.Error(), "PFX file not found") +} + +// makeTestPFX generates a self-signed code-signing cert and writes it as a +// password-protected PKCS#12 file, returning its path and passphrase. +func makeTestPFX(t *testing.T) (string, string) { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "nfpm-msi-test"}, + NotBefore: time.Unix(0, 0), + NotAfter: time.Unix(0, 0).AddDate(20, 0, 0), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + + const passphrase = "test123" + pfx, err := pkcs12.Modern.Encode(key, cert, nil, passphrase) + require.NoError(t, err) + + path := filepath.Join(t.TempDir(), "test.pfx") + require.NoError(t, os.WriteFile(path, pfx, 0o600)) + return path, passphrase +} diff --git a/nfpm.go b/nfpm.go index 25007b0f..3235f086 100644 --- a/nfpm.go +++ b/nfpm.go @@ -290,6 +290,14 @@ func (c *Config) expandEnvVars() { if msixPassphrase != "" { c.MSIX.Signature.KeyPassphrase = msixPassphrase } + + // MSI specific + c.MSI.Signature.PFXFile = os.Expand(c.MSI.Signature.PFXFile, c.envMappingFunc) + c.MSI.Manufacturer = os.Expand(c.MSI.Manufacturer, c.envMappingFunc) + msiPassphrase := os.Expand("$NFPM_MSI_PASSPHRASE", c.envMappingFunc) + if msiPassphrase != "" { + c.MSI.Signature.KeyPassphrase = msiPassphrase + } } // Info contains information about a single package. @@ -372,6 +380,7 @@ type Overridables struct { 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"` MSIX MSIX `yaml:"msix,omitempty" json:"msix,omitempty" jsonschema:"title=msix-specific settings"` + MSI MSI `yaml:"msi,omitempty" json:"msi,omitempty" jsonschema:"title=msi-specific settings"` } type ArchLinux struct { @@ -573,6 +582,70 @@ type MSIXSignature struct { KeyPassphrase string `yaml:"-" json:"-"` // populated from NFPM_MSIX_PASSPHRASE env var } +// MSI contains configs that are only available on MSI packages. +type MSI struct { + Arch string `yaml:"arch,omitempty" json:"arch,omitempty" jsonschema:"title=architecture in msi nomenclature"` + ProductName string `yaml:"product_name,omitempty" json:"product_name,omitempty" jsonschema:"title=product name,description=defaults to the package name"` + Manufacturer string `yaml:"manufacturer" json:"manufacturer" jsonschema:"title=manufacturer/author of the product,example=My Company"` + ProductCode string `yaml:"product_code,omitempty" json:"product_code,omitempty" jsonschema:"title=product code GUID,description=derived from the product name (stable across versions) when empty,example={12345678-1234-1234-1234-123456789ABC}"` + UpgradeCode string `yaml:"upgrade_code,omitempty" json:"upgrade_code,omitempty" jsonschema:"title=upgrade code GUID,description=derived from the product name when empty,example={ABCDEF01-2345-6789-ABCD-EF0123456789}"` + InstallDir string `yaml:"install_dir,omitempty" json:"install_dir,omitempty" jsonschema:"title=default install folder name,description=defaults to the product name"` + AllUsers *bool `yaml:"all_users,omitempty" json:"all_users,omitempty" jsonschema:"title=per-machine install,description=defaults to true"` + Properties map[string]string `yaml:"properties,omitempty" json:"properties,omitempty" jsonschema:"title=arbitrary MSI Property rows"` + License string `yaml:"license,omitempty" json:"license,omitempty" jsonschema:"title=path to a license text file shown by the UI"` + MinimalUI bool `yaml:"minimal_ui,omitempty" json:"minimal_ui,omitempty" jsonschema:"title=install the canned minimal install wizard"` + Upgrade MSIUpgrade `yaml:"upgrade,omitempty" json:"upgrade,omitempty" jsonschema:"title=major upgrade behavior"` + Shortcuts []MSIShortcut `yaml:"shortcuts,omitempty" json:"shortcuts,omitempty" jsonschema:"title=shortcuts to create"` + Services []MSIService `yaml:"services,omitempty" json:"services,omitempty" jsonschema:"title=windows services to install"` + Registry []MSIRegistry `yaml:"registry,omitempty" json:"registry,omitempty" jsonschema:"title=registry entries to create"` + Signature MSISignature `yaml:"signature,omitempty" json:"signature,omitempty" jsonschema:"title=msi signature"` +} + +// MSIUpgrade contains major-upgrade configuration for MSI packages. +type MSIUpgrade struct { + Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty" jsonschema:"title=enable WiX-style major upgrade handling"` + DowngradeErrorMessage string `yaml:"downgrade_error_message,omitempty" json:"downgrade_error_message,omitempty" jsonschema:"title=message shown when a newer version is already installed"` +} + +// MSIShortcut describes an advertised shortcut in an MSI package. +type MSIShortcut struct { + Name string `yaml:"name" json:"name" jsonschema:"title=shortcut display name"` + Target string `yaml:"target" json:"target" jsonschema:"title=destination path of an installed file the shortcut points to"` + Directory string `yaml:"directory,omitempty" json:"directory,omitempty" jsonschema:"title=standard folder ID,default=ProgramMenuFolder,example=DesktopFolder"` + Arguments string `yaml:"arguments,omitempty" json:"arguments,omitempty" jsonschema:"title=command-line arguments"` + Description string `yaml:"description,omitempty" json:"description,omitempty" jsonschema:"title=shortcut description"` + Icon string `yaml:"icon,omitempty" json:"icon,omitempty" jsonschema:"title=path to an icon file"` +} + +// MSIService describes a Windows service to install from an MSI package. +type MSIService struct { + Name string `yaml:"name" json:"name" jsonschema:"title=service name"` + DisplayName string `yaml:"display_name,omitempty" json:"display_name,omitempty" jsonschema:"title=service display name"` + Executable string `yaml:"executable" json:"executable" jsonschema:"title=destination path of the installed service executable"` + Description string `yaml:"description,omitempty" json:"description,omitempty" jsonschema:"title=service description"` + StartType string `yaml:"start_type,omitempty" json:"start_type,omitempty" jsonschema:"title=service start type,enum=auto,enum=demand,enum=disabled,enum=boot,enum=system,default=demand"` + Account string `yaml:"account,omitempty" json:"account,omitempty" jsonschema:"title=account the service runs as"` + Arguments string `yaml:"arguments,omitempty" json:"arguments,omitempty" jsonschema:"title=service arguments"` + Dependencies []string `yaml:"dependencies,omitempty" json:"dependencies,omitempty" jsonschema:"title=service dependencies"` + Start bool `yaml:"start,omitempty" json:"start,omitempty" jsonschema:"title=start the service on install"` + Stop bool `yaml:"stop,omitempty" json:"stop,omitempty" jsonschema:"title=stop and delete the service on uninstall"` +} + +// MSIRegistry describes a registry entry to create from an MSI package. +type MSIRegistry struct { + Root string `yaml:"root" json:"root" jsonschema:"title=registry root,enum=HKLM,enum=HKCU,enum=HKCR,enum=HKMU,enum=HKU"` + Key string `yaml:"key" json:"key" jsonschema:"title=registry key path"` + Name string `yaml:"name,omitempty" json:"name,omitempty" jsonschema:"title=value name"` + Value string `yaml:"value,omitempty" json:"value,omitempty" jsonschema:"title=value data"` +} + +// MSISignature contains signing configuration for MSI packages. +type MSISignature struct { + PFXFile string `yaml:"pfx_file,omitempty" json:"pfx_file,omitempty" jsonschema:"title=PFX certificate file"` + TimestampURL string `yaml:"timestamp_url,omitempty" json:"timestamp_url,omitempty" jsonschema:"title=RFC3161 timestamp URL"` + KeyPassphrase string `yaml:"-" json:"-"` // populated from NFPM_MSI_PASSPHRASE env var +} + // Scripts contains information about maintainer scripts for packages. type Scripts struct { PreInstall string `yaml:"preinstall,omitempty" json:"preinstall,omitempty" jsonschema:"title=pre install"` diff --git a/testdata/acceptance/install-msi.ps1 b/testdata/acceptance/install-msi.ps1 new file mode 100644 index 00000000..eada3a85 --- /dev/null +++ b/testdata/acceptance/install-msi.ps1 @@ -0,0 +1,36 @@ +$ErrorActionPreference = 'Stop' + +$msi = Resolve-Path ./dist/foo.msi +$log = "./dist/install.log" + +Write-Host "Installing MSI: $msi ($((Get-Item $msi).Length) bytes)" + +$proc = Start-Process msiexec.exe -ArgumentList "/i `"$msi`" /qn /norestart /l*v `"$log`"" -Wait -PassThru +if ($proc.ExitCode -ne 0) { + Write-Host "msiexec install failed with exit code $($proc.ExitCode)" + if (Test-Path $log) { Get-Content $log | Write-Host } + exit 1 +} +Write-Host "Package installed successfully" + +# Verify the installed executable runs and prints the expected sentinel. +$exe = "C:\Program Files\NfpmMsiTest\testapp.exe" +if (-not (Test-Path $exe)) { + Write-Error "Installed executable not found at $exe" + exit 1 +} + +$output = & $exe 2>&1 +if ($output -ne "nfpm-msix-test-ok") { + Write-Error "Expected 'nfpm-msix-test-ok' but got '$output'" + exit 1 +} +Write-Host "Installed application ran correctly" + +# Uninstall. +$proc = Start-Process msiexec.exe -ArgumentList "/x `"$msi`" /qn /norestart" -Wait -PassThru +if ($proc.ExitCode -ne 0) { + Write-Error "msiexec uninstall failed with exit code $($proc.ExitCode)" + exit 1 +} +Write-Host "Package uninstalled successfully" diff --git a/testdata/acceptance/msi.basic.yaml b/testdata/acceptance/msi.basic.yaml new file mode 100644 index 00000000..8692bf06 --- /dev/null +++ b/testdata/acceptance/msi.basic.yaml @@ -0,0 +1,28 @@ +name: TestApp +arch: "${BUILD_ARCH}" +version: 1.2.3 +license: MIT +maintainer: "Foo Bar" +description: "A test MSI package" +contents: + - src: ./testdata/fake + dst: "/Program Files/TestApp/app.exe" + - src: ./testdata/acceptance/testapp/logo.png + dst: "/Program Files/TestApp/assets/logo.png" + - src: ./testdata/whatever.conf + dst: config.conf +msi: + manufacturer: "Test Company" + upgrade_code: "{ABCDEF01-2345-6789-ABCD-EF0123456789}" + shortcuts: + - name: "Test App" + target: "/Program Files/TestApp/app.exe" + description: "Launch Test App" + registry: + - root: HKLM + key: 'Software\TestCo\TestApp' + name: InstallPath + value: "C:\\Program Files\\TestApp" + upgrade: + enabled: true + downgrade_error_message: "A newer version of TestApp is already installed." diff --git a/testdata/acceptance/msi.install.yaml b/testdata/acceptance/msi.install.yaml new file mode 100644 index 00000000..039bb06c --- /dev/null +++ b/testdata/acceptance/msi.install.yaml @@ -0,0 +1,17 @@ +name: TestApp +arch: amd64 +version: 1.0.0 +license: MIT +maintainer: "Test Company" +description: "A test MSI package for Windows installation" +contents: + - src: ./dist/testapp.exe + dst: "/Program Files/NfpmMsiTest/testapp.exe" +msi: + product_name: NfpmMsiTest + manufacturer: "Test Company" + upgrade_code: "{ABCDEF01-2345-6789-ABCD-EF0123456789}" + shortcuts: + - name: "NfpmMsiTest" + target: "/Program Files/NfpmMsiTest/testapp.exe" + description: "Launch NfpmMsiTest" diff --git a/www/content/docs/configuration.md b/www/content/docs/configuration.md index a5f95562..960903e5 100644 --- a/www/content/docs/configuration.md +++ b/www/content/docs/configuration.md @@ -616,6 +616,99 @@ msix: # Path to the PFX certificate file. pfx_file: certificate.pfx # Passphrase is read from the NFPM_MSIX_PASSPHRASE environment variable. + +# Custom configuration applied only to the MSI packager (Windows). +# The MSI packager produces a real Windows Installer database. Use unix-style +# destinations (a leading "/", no drive letter). Well-known prefixes such as +# `/Program Files`, `/ProgramData` and `/Windows/System32` are mapped to the +# matching Windows Installer system folders; anything else is installed under the +# product's install folder. +msi: + # msi specific architecture name that overrides "arch" without performing + # any replacements. + arch: x64 + + # Product name (defaults to the package name). + product_name: "My Application" + + # Manufacturer/author of the product. (required) + manufacturer: "My Company" + + # Product code GUID. When omitted, a stable GUID is derived from the product + # name (kept constant across versions). + product_code: "{12345678-1234-1234-1234-123456789ABC}" + + # Upgrade code GUID. When omitted, a stable GUID is derived from the product + # name (kept constant across versions so upgrades work). + upgrade_code: "{ABCDEF01-2345-6789-ABCD-EF0123456789}" + + # Name of the default install folder (defaults to product_name). + install_dir: "My Application" + + # Per-machine install (defaults to true). + all_users: true + + # Arbitrary MSI Property rows. + properties: + MYPROPERTY: "value" + + # Path to a license text file shown by the install wizard. + license: ./LICENSE.txt + + # Install the canned minimal install wizard. + minimal_ui: true + + # Major upgrade behavior. + upgrade: + # Enable WiX-style major upgrade handling (remove older, block downgrade). + enabled: true + # Message shown when a newer version is already installed. + downgrade_error_message: "A newer version is already installed." + + # Advertised shortcuts. The target must match one of the contents + # destinations. + shortcuts: + - name: "My Application" + target: "/Program Files/My Application/myapp.exe" + # Standard folder ID the shortcut is created in. + # Defaults to ProgramMenuFolder; e.g. DesktopFolder. + directory: ProgramMenuFolder + arguments: "" + description: "Launch My Application" + icon: ./assets/app.ico + + # Windows services to install. The executable must match one of the contents + # destinations. + services: + - name: MyService + display_name: "My Service" + executable: "/Program Files/My Application/svc.exe" + description: "My background service" + # auto | demand | disabled | boot | system (defaults to demand). + start_type: auto + account: "" + arguments: "" + dependencies: [] + # Start the service on install. + start: true + # Stop and delete the service on uninstall. + stop: true + + # Registry entries to create. + registry: + - root: HKLM # HKLM | HKCU | HKCR | HKMU | HKU + key: 'Software\MyCompany\MyApp' + name: InstallPath + value: "C:\\Program Files\\My Application" + + # MSI signing configuration (Authenticode). + # Uses PFX certificates (not PGP like Linux packagers). + signature: + # Path to the PFX certificate file. + pfx_file: certificate.pfx + # Optional RFC3161 timestamp URL. + timestamp_url: "http://timestamp.digicert.com" + # Passphrase is read from the NFPM_MSI_PASSPHRASE environment variable. ``` ## Templating diff --git a/www/static/schema.json b/www/static/schema.json index 1200c064..f6991da5 100644 --- a/www/static/schema.json +++ b/www/static/schema.json @@ -200,6 +200,10 @@ "$ref": "#/$defs/MSIX", "title": "msix-specific settings" }, + "msi": { + "$ref": "#/$defs/MSI", + "title": "msi-specific settings" + }, "name": { "type": "string", "title": "package name" @@ -652,6 +656,261 @@ "additionalProperties": false, "type": "object" }, + "MSI": { + "properties": { + "arch": { + "type": "string", + "title": "architecture in msi nomenclature" + }, + "product_name": { + "type": "string", + "title": "product name", + "description": "defaults to the package name" + }, + "manufacturer": { + "type": "string", + "title": "manufacturer/author of the product", + "examples": [ + "My Company" + ] + }, + "product_code": { + "type": "string", + "title": "product code GUID", + "description": "derived from the product name (stable across versions) when empty", + "examples": [ + "{12345678-1234-1234-1234-123456789ABC}" + ] + }, + "upgrade_code": { + "type": "string", + "title": "upgrade code GUID", + "description": "derived from the product name when empty", + "examples": [ + "{ABCDEF01-2345-6789-ABCD-EF0123456789}" + ] + }, + "install_dir": { + "type": "string", + "title": "default install folder name", + "description": "defaults to the product name" + }, + "all_users": { + "type": "boolean", + "title": "per-machine install", + "description": "defaults to true" + }, + "properties": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "arbitrary MSI Property rows" + }, + "license": { + "type": "string", + "title": "path to a license text file shown by the UI" + }, + "minimal_ui": { + "type": "boolean", + "title": "install the canned minimal install wizard" + }, + "upgrade": { + "$ref": "#/$defs/MSIUpgrade", + "title": "major upgrade behavior" + }, + "shortcuts": { + "items": { + "$ref": "#/$defs/MSIShortcut" + }, + "type": "array", + "title": "shortcuts to create" + }, + "services": { + "items": { + "$ref": "#/$defs/MSIService" + }, + "type": "array", + "title": "windows services to install" + }, + "registry": { + "items": { + "$ref": "#/$defs/MSIRegistry" + }, + "type": "array", + "title": "registry entries to create" + }, + "signature": { + "$ref": "#/$defs/MSISignature", + "title": "msi signature" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "manufacturer" + ] + }, + "MSIRegistry": { + "properties": { + "root": { + "type": "string", + "enum": [ + "HKLM", + "HKCU", + "HKCR", + "HKMU", + "HKU" + ], + "title": "registry root" + }, + "key": { + "type": "string", + "title": "registry key path" + }, + "name": { + "type": "string", + "title": "value name" + }, + "value": { + "type": "string", + "title": "value data" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "root", + "key" + ] + }, + "MSIService": { + "properties": { + "name": { + "type": "string", + "title": "service name" + }, + "display_name": { + "type": "string", + "title": "service display name" + }, + "executable": { + "type": "string", + "title": "destination path of the installed service executable" + }, + "description": { + "type": "string", + "title": "service description" + }, + "start_type": { + "type": "string", + "enum": [ + "auto", + "demand", + "disabled", + "boot", + "system" + ], + "title": "service start type", + "default": "demand" + }, + "account": { + "type": "string", + "title": "account the service runs as" + }, + "arguments": { + "type": "string", + "title": "service arguments" + }, + "dependencies": { + "items": { + "type": "string" + }, + "type": "array", + "title": "service dependencies" + }, + "start": { + "type": "boolean", + "title": "start the service on install" + }, + "stop": { + "type": "boolean", + "title": "stop and delete the service on uninstall" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "executable" + ] + }, + "MSIShortcut": { + "properties": { + "name": { + "type": "string", + "title": "shortcut display name" + }, + "target": { + "type": "string", + "title": "destination path of an installed file the shortcut points to" + }, + "directory": { + "type": "string", + "title": "standard folder ID", + "default": "ProgramMenuFolder", + "examples": [ + "DesktopFolder" + ] + }, + "arguments": { + "type": "string", + "title": "command-line arguments" + }, + "description": { + "type": "string", + "title": "shortcut description" + }, + "icon": { + "type": "string", + "title": "path to an icon file" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "target" + ] + }, + "MSISignature": { + "properties": { + "pfx_file": { + "type": "string", + "title": "PFX certificate file" + }, + "timestamp_url": { + "type": "string", + "title": "RFC3161 timestamp URL" + } + }, + "additionalProperties": false, + "type": "object" + }, + "MSIUpgrade": { + "properties": { + "enabled": { + "type": "boolean", + "title": "enable WiX-style major upgrade handling" + }, + "downgrade_error_message": { + "type": "string", + "title": "message shown when a newer version is already installed" + } + }, + "additionalProperties": false, + "type": "object" + }, "MSIX": { "properties": { "arch": { @@ -955,6 +1214,10 @@ "msix": { "$ref": "#/$defs/MSIX", "title": "msix-specific settings" + }, + "msi": { + "$ref": "#/$defs/MSI", + "title": "msi-specific settings" } }, "additionalProperties": false,