Skip to content

Commit 5c0140d

Browse files
bradenhiltontwpayne
authored andcommittedOct 7, 2023
fix: Don't use replace-executable for WinGet installations
1 parent 9d017bb commit 5c0140d

File tree

3 files changed

+428
-27
lines changed

3 files changed

+428
-27
lines changed
 

‎internal/cmd/upgradecmd.go

+3-22
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//go:build !noupgrade
1+
//go:build !noupgrade && !windows
22

33
package cmd
44

@@ -331,15 +331,6 @@ func (c *Config) replaceExecutable(
331331
var archiveFormat chezmoi.ArchiveFormat
332332
var archiveName string
333333
switch {
334-
case runtime.GOOS == "windows":
335-
archiveFormat = chezmoi.ArchiveFormatZip
336-
archiveName = fmt.Sprintf(
337-
"%s_%s_%s_%s.zip",
338-
c.upgrade.repo,
339-
releaseVersion,
340-
runtime.GOOS,
341-
runtime.GOARCH,
342-
)
343334
case runtime.GOOS == "linux" && runtime.GOARCH == "amd64":
344335
archiveFormat = chezmoi.ArchiveFormatTarGz
345336
var libc string
@@ -389,19 +380,15 @@ func (c *Config) replaceExecutable(
389380
// Extract the executable from the archive.
390381
var executableData []byte
391382
walkArchiveFunc := func(name string, info fs.FileInfo, r io.Reader, linkname string) error {
392-
switch {
393-
case runtime.GOOS != "windows" && name == c.upgrade.repo:
394-
fallthrough
395-
case runtime.GOOS == "windows" && name == c.upgrade.repo+".exe":
383+
if name == c.upgrade.repo {
396384
var err error
397385
executableData, err = io.ReadAll(r)
398386
if err != nil {
399387
return err
400388
}
401389
return fs.SkipAll
402-
default:
403-
return nil
404390
}
391+
return nil
405392
}
406393
if err = chezmoi.WalkArchive(archiveData, archiveFormat, walkArchiveFunc); err != nil {
407394
return
@@ -411,12 +398,6 @@ func (c *Config) replaceExecutable(
411398
return
412399
}
413400

414-
// Replace the executable.
415-
if runtime.GOOS == "windows" {
416-
if err = c.baseSystem.Rename(executableFilenameAbsPath, executableFilenameAbsPath.Append(".old")); err != nil {
417-
return
418-
}
419-
}
420401
err = c.baseSystem.WriteFile(executableFilenameAbsPath, executableData, 0o755)
421402

422403
return

‎internal/cmd/upgradecmd_windows.go

+425
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,425 @@
1+
//go:build !noupgrade
2+
3+
package cmd
4+
5+
import (
6+
"bufio"
7+
"bytes"
8+
"context"
9+
"crypto/sha256"
10+
"encoding/hex"
11+
"errors"
12+
"fmt"
13+
"io"
14+
"io/fs"
15+
"net/http"
16+
"os"
17+
"os/exec"
18+
"path/filepath"
19+
"regexp"
20+
"runtime"
21+
"strings"
22+
23+
"github.com/coreos/go-semver/semver"
24+
"github.com/google/go-github/v55/github"
25+
"github.com/spf13/cobra"
26+
vfs "github.com/twpayne/go-vfs/v4"
27+
28+
"github.com/twpayne/chezmoi/v2/internal/chezmoi"
29+
"github.com/twpayne/chezmoi/v2/internal/chezmoilog"
30+
)
31+
32+
const (
33+
upgradeMethodReplaceExecutable = "replace-executable"
34+
upgradeMethodWinGetUpgrade = "winget-upgrade"
35+
)
36+
37+
var checksumRx = regexp.MustCompile(`\A([0-9a-f]{64})\s+(\S+)\z`)
38+
39+
type upgradeCmdConfig struct {
40+
executable string
41+
method string
42+
owner string
43+
repo string
44+
}
45+
46+
type InstallBehavior struct {
47+
PortablePackageUserRoot string `json:"portablePackageUserRoot"`
48+
PortablePackageMachineRoot string `json:"portablePackageMachineRoot"`
49+
}
50+
51+
func (ib *InstallBehavior) Values() []string {
52+
return []string{
53+
ib.PortablePackageUserRoot,
54+
ib.PortablePackageMachineRoot,
55+
}
56+
}
57+
58+
type WinGetSettings struct {
59+
InstallBehavior InstallBehavior `json:"installBehavior"`
60+
}
61+
62+
func (c *Config) newUpgradeCmd() *cobra.Command {
63+
upgradeCmd := &cobra.Command{
64+
Use: "upgrade",
65+
Short: "Upgrade chezmoi to the latest released version",
66+
Long: mustLongHelp("upgrade"),
67+
Example: example("upgrade"),
68+
Args: cobra.NoArgs,
69+
RunE: c.runUpgradeCmd,
70+
Annotations: newAnnotations(
71+
runsCommands,
72+
),
73+
}
74+
75+
flags := upgradeCmd.Flags()
76+
flags.StringVar(
77+
&c.upgrade.executable,
78+
"executable",
79+
c.upgrade.method,
80+
"Set executable to replace",
81+
)
82+
flags.StringVar(&c.upgrade.method, "method", c.upgrade.method, "Set upgrade method")
83+
flags.StringVar(&c.upgrade.owner, "owner", c.upgrade.owner, "Set owner")
84+
flags.StringVar(&c.upgrade.repo, "repo", c.upgrade.repo, "Set repo")
85+
86+
return upgradeCmd
87+
}
88+
89+
func (c *Config) runUpgradeCmd(cmd *cobra.Command, args []string) error {
90+
ctx, cancel := context.WithCancel(context.Background())
91+
defer cancel()
92+
93+
var zeroVersion semver.Version
94+
if c.version == zeroVersion && !c.force {
95+
return errors.New(
96+
"cannot upgrade dev version to latest released version unless --force is set",
97+
)
98+
}
99+
100+
httpClient, err := c.getHTTPClient()
101+
if err != nil {
102+
return err
103+
}
104+
client := chezmoi.NewGitHubClient(ctx, httpClient)
105+
106+
// Get the latest release.
107+
rr, _, err := client.Repositories.GetLatestRelease(ctx, c.upgrade.owner, c.upgrade.repo)
108+
if err != nil {
109+
return err
110+
}
111+
version, err := semver.NewVersion(strings.TrimPrefix(rr.GetName(), "v"))
112+
if err != nil {
113+
return err
114+
}
115+
116+
// If the upgrade is not forced, stop if we're already the latest version.
117+
// Print a message and return no error so the command exits with success.
118+
if !c.force && !c.version.LessThan(*version) {
119+
fmt.Fprintf(c.stdout, "chezmoi: already at the latest version (%s)\n", c.version)
120+
return nil
121+
}
122+
123+
// Determine the upgrade method to use.
124+
if c.upgrade.executable == "" {
125+
executable, err := os.Executable()
126+
if err != nil {
127+
return err
128+
}
129+
c.upgrade.executable = executable
130+
}
131+
132+
executableAbsPath := chezmoi.NewAbsPath(c.upgrade.executable)
133+
method := c.upgrade.method
134+
if method == "" {
135+
switch method, err = getUpgradeMethod(c.fileSystem, executableAbsPath); {
136+
case err != nil:
137+
return err
138+
case method == "":
139+
return fmt.Errorf(
140+
"%s/%s: cannot determine upgrade method for %s",
141+
runtime.GOOS,
142+
runtime.GOARCH,
143+
executableAbsPath,
144+
)
145+
}
146+
}
147+
c.logger.Info().
148+
Str("executable", c.upgrade.executable).
149+
Str("method", method).
150+
Msg("upgradeMethod")
151+
152+
// Replace the executable with the updated version.
153+
switch method {
154+
case upgradeMethodReplaceExecutable:
155+
if err := c.replaceExecutable(ctx, executableAbsPath, version, rr); err != nil {
156+
return err
157+
}
158+
case upgradeMethodWinGetUpgrade:
159+
if err := c.winGetUpgrade(); err != nil {
160+
return err
161+
}
162+
default:
163+
return fmt.Errorf("%s: invalid method", method)
164+
}
165+
166+
// Find the executable. If we replaced the executable directly, then use
167+
// that, otherwise look in $PATH.
168+
path := c.upgrade.executable
169+
if method != upgradeMethodReplaceExecutable {
170+
path, err = chezmoi.LookPath(c.upgrade.repo)
171+
if err != nil {
172+
return err
173+
}
174+
}
175+
176+
// Execute the new version.
177+
chezmoiVersionCmd := exec.Command(path, "--version")
178+
chezmoiVersionCmd.Stdin = os.Stdin
179+
chezmoiVersionCmd.Stdout = os.Stdout
180+
chezmoiVersionCmd.Stderr = os.Stderr
181+
return chezmoilog.LogCmdRun(chezmoiVersionCmd)
182+
}
183+
184+
func (c *Config) getChecksums(
185+
ctx context.Context,
186+
rr *github.RepositoryRelease,
187+
) (map[string][]byte, error) {
188+
name := fmt.Sprintf(
189+
"%s_%s_checksums.txt",
190+
c.upgrade.repo,
191+
strings.TrimPrefix(rr.GetTagName(), "v"),
192+
)
193+
releaseAsset := getReleaseAssetByName(rr, name)
194+
if releaseAsset == nil {
195+
return nil, fmt.Errorf("%s: cannot find release asset", name)
196+
}
197+
198+
data, err := c.downloadURL(ctx, releaseAsset.GetBrowserDownloadURL())
199+
if err != nil {
200+
return nil, err
201+
}
202+
203+
checksums := make(map[string][]byte)
204+
s := bufio.NewScanner(bytes.NewReader(data))
205+
for s.Scan() {
206+
m := checksumRx.FindStringSubmatch(s.Text())
207+
if m == nil {
208+
return nil, fmt.Errorf("%q: cannot parse checksum", s.Text())
209+
}
210+
checksums[m[2]], _ = hex.DecodeString(m[1])
211+
}
212+
return checksums, s.Err()
213+
}
214+
215+
func (c *Config) downloadURL(ctx context.Context, url string) ([]byte, error) {
216+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
217+
if err != nil {
218+
return nil, err
219+
}
220+
httpClient, err := c.getHTTPClient()
221+
if err != nil {
222+
return nil, err
223+
}
224+
resp, err := chezmoilog.LogHTTPRequest(c.logger, httpClient, req)
225+
if err != nil {
226+
return nil, err
227+
}
228+
if resp.StatusCode != http.StatusOK {
229+
_ = resp.Body.Close()
230+
return nil, fmt.Errorf("%s: %s", url, resp.Status)
231+
}
232+
data, err := io.ReadAll(resp.Body)
233+
if err != nil {
234+
return nil, err
235+
}
236+
if err := resp.Body.Close(); err != nil {
237+
return nil, err
238+
}
239+
return data, nil
240+
}
241+
242+
func (c *Config) replaceExecutable(
243+
ctx context.Context, executableFilenameAbsPath chezmoi.AbsPath, releaseVersion *semver.Version,
244+
rr *github.RepositoryRelease,
245+
) (err error) {
246+
var archiveFormat chezmoi.ArchiveFormat
247+
var archiveName string
248+
archiveFormat = chezmoi.ArchiveFormatZip
249+
archiveName = fmt.Sprintf(
250+
"%s_%s_%s_%s.zip",
251+
c.upgrade.repo,
252+
releaseVersion,
253+
runtime.GOOS,
254+
runtime.GOARCH,
255+
)
256+
releaseAsset := getReleaseAssetByName(rr, archiveName)
257+
if releaseAsset == nil {
258+
err = fmt.Errorf("%s: cannot find release asset", archiveName)
259+
return
260+
}
261+
262+
var archiveData []byte
263+
if archiveData, err = c.downloadURL(ctx, releaseAsset.GetBrowserDownloadURL()); err != nil {
264+
return
265+
}
266+
if err = c.verifyChecksum(ctx, rr, releaseAsset.GetName(), archiveData); err != nil {
267+
return
268+
}
269+
270+
// Extract the executable from the archive.
271+
var executableData []byte
272+
walkArchiveFunc := func(name string, info fs.FileInfo, r io.Reader, linkname string) error {
273+
if name == c.upgrade.repo+".exe" {
274+
var err error
275+
executableData, err = io.ReadAll(r)
276+
if err != nil {
277+
return err
278+
}
279+
return fs.SkipAll
280+
}
281+
return nil
282+
}
283+
if err = chezmoi.WalkArchive(archiveData, archiveFormat, walkArchiveFunc); err != nil {
284+
return
285+
}
286+
if executableData == nil {
287+
err = fmt.Errorf("%s: cannot find executable in archive", archiveName)
288+
return
289+
}
290+
291+
// Replace the executable.
292+
if err = c.baseSystem.Rename(executableFilenameAbsPath, executableFilenameAbsPath.Append(".old")); err != nil {
293+
return
294+
}
295+
err = c.baseSystem.WriteFile(executableFilenameAbsPath, executableData, 0o755)
296+
297+
return
298+
}
299+
300+
func (c *Config) verifyChecksum(
301+
ctx context.Context,
302+
rr *github.RepositoryRelease,
303+
name string,
304+
data []byte,
305+
) error {
306+
checksums, err := c.getChecksums(ctx, rr)
307+
if err != nil {
308+
return err
309+
}
310+
expectedChecksum, ok := checksums[name]
311+
if !ok {
312+
return fmt.Errorf("%s: checksum not found", name)
313+
}
314+
checksum := sha256.Sum256(data)
315+
if !bytes.Equal(checksum[:], expectedChecksum) {
316+
return fmt.Errorf(
317+
"%s: checksum failed (want %s, got %s)",
318+
name,
319+
hex.EncodeToString(expectedChecksum),
320+
hex.EncodeToString(checksum[:]),
321+
)
322+
}
323+
return nil
324+
}
325+
326+
// isWinGetInstall determines if executableAbsPath contains a WinGet installation path.
327+
func isWinGetInstall(fileSystem vfs.Stater, executableAbsPath string) (bool, error) {
328+
realExecutableAbsPath := executableAbsPath
329+
fi, err := os.Lstat(executableAbsPath)
330+
if err != nil {
331+
return false, err
332+
}
333+
if fi.Mode().Type() == fs.ModeSymlink {
334+
realExecutableAbsPath, err = os.Readlink(executableAbsPath)
335+
if err != nil {
336+
return false, err
337+
}
338+
}
339+
winGetSettings := WinGetSettings{
340+
InstallBehavior: InstallBehavior{
341+
PortablePackageUserRoot: os.ExpandEnv(`${LOCALAPPDATA}\Microsoft\WinGet\Packages\`),
342+
PortablePackageMachineRoot: os.ExpandEnv(`${PROGRAMFILES}\WinGet\Packages\`),
343+
},
344+
}
345+
settingsPaths := []string{
346+
os.ExpandEnv(
347+
`${LOCALAPPDATA}\Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState\settings.json`,
348+
),
349+
os.ExpandEnv(`${LOCALAPPDATA}\Microsoft\WinGet\Settings\settings.json`),
350+
}
351+
for _, settingsPath := range settingsPaths {
352+
if _, err := os.Stat(settingsPath); err == nil {
353+
winGetSettingsContents, err := os.ReadFile(settingsPath)
354+
if err == nil {
355+
if err := chezmoi.FormatJSONC.Unmarshal(winGetSettingsContents, &winGetSettings); err != nil {
356+
return false, err
357+
}
358+
}
359+
}
360+
}
361+
for _, path := range winGetSettings.InstallBehavior.Values() {
362+
path = filepath.Clean(path)
363+
if path == "." {
364+
continue
365+
}
366+
if ok, _ := vfs.Contains(fileSystem, realExecutableAbsPath, path); ok {
367+
return true, nil
368+
}
369+
}
370+
return false, nil
371+
}
372+
373+
func (c *Config) winGetUpgrade() error {
374+
return fmt.Errorf(
375+
"upgrade command is not currently supported for WinGet installations. chezmoi can still be upgraded via WinGet by running `winget upgrade --id %s.%s --source winget`",
376+
c.upgrade.owner,
377+
c.upgrade.repo,
378+
)
379+
}
380+
381+
// getUpgradeMethod attempts to determine the method by which chezmoi can be
382+
// upgraded by looking at how it was installed.
383+
func getUpgradeMethod(fileSystem vfs.Stater, executableAbsPath chezmoi.AbsPath) (string, error) {
384+
if ok, err := isWinGetInstall(fileSystem, executableAbsPath.String()); err != nil {
385+
return "", err
386+
} else if ok {
387+
return upgradeMethodWinGetUpgrade, nil
388+
}
389+
390+
// If the executable is in the user's home directory, then always use
391+
// replace-executable.
392+
switch userHomeDir, err := os.UserHomeDir(); {
393+
case errors.Is(err, fs.ErrNotExist):
394+
case err != nil:
395+
return "", err
396+
default:
397+
switch executableInUserHomeDir, err := vfs.Contains(fileSystem, executableAbsPath.String(), userHomeDir); {
398+
case errors.Is(err, fs.ErrNotExist):
399+
case err != nil:
400+
return "", err
401+
case executableInUserHomeDir:
402+
return upgradeMethodReplaceExecutable, nil
403+
}
404+
}
405+
406+
// If the executable is in the system's temporary directory, then always use
407+
// replace-executable.
408+
if executableIsInTempDir, err := vfs.Contains(fileSystem, executableAbsPath.String(), os.TempDir()); err != nil {
409+
return "", err
410+
} else if executableIsInTempDir {
411+
return upgradeMethodReplaceExecutable, nil
412+
}
413+
414+
return "", nil
415+
}
416+
417+
// getReleaseAssetByName returns the release asset from rr with the given name.
418+
func getReleaseAssetByName(rr *github.RepositoryRelease, name string) *github.ReleaseAsset {
419+
for i, ra := range rr.Assets {
420+
if ra.GetName() == name {
421+
return rr.Assets[i]
422+
}
423+
}
424+
return nil
425+
}

‎internal/cmd/util_windows.go

-5
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package cmd
22

33
import (
44
"fmt"
5-
"io/fs"
65
"strings"
76

87
"golang.org/x/sys/windows/registry"
@@ -35,10 +34,6 @@ var defaultInterpreters = map[string]*chezmoi.Interpreter{
3534
},
3635
}
3736

38-
func fileInfoUID(fs.FileInfo) int {
39-
return 0
40-
}
41-
4237
func windowsVersion() (map[string]any, error) {
4338
registryKey, err := registry.OpenKey(
4439
registry.LOCAL_MACHINE,

0 commit comments

Comments
 (0)
Please sign in to comment.