Skip to content

Commit 8963e6d

Browse files
wesmclaude
andauthored
Windows update support: .zip archives, .exe binary, testable pipeline (#67)
## Summary - Add Windows support for the self-update command: use `.zip` archives and `msgvault.exe` on Windows instead of `.tar.gz` and `msgvault` - Extract `installFromArchiveTo` for a testable update-from-archive pipeline with zip extraction, path-traversal protection, and table-driven tests - Avoid double-hashing during update download by plumbing the SHA-256 computed during download through to checksum verification, eliminating a redundant full file re-read ## Test plan - [x] All existing update tests pass (`go test ./internal/update/`) - [x] Full project test suite passes (`make test`) - [x] New tests cover: zip extraction, zip path traversal, tar.gz path traversal, symlink skipping, `installFromArchiveTo` happy paths (zip + tar.gz), checksum mismatch, empty checksum, missing binary, overwrite existing binary - [x] Manual test on Windows: `msgvault update` downloads `.zip` and extracts `.exe` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 0a5edd4 commit 8963e6d

File tree

6 files changed

+561
-35
lines changed

6 files changed

+561
-35
lines changed

cmd/msgvault/cmd/build_cache.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
_ "github.com/marcboeker/go-duckdb"
1515
_ "github.com/mattn/go-sqlite3"
1616
"github.com/spf13/cobra"
17+
"github.com/wesm/msgvault/internal/config"
1718
)
1819

1920
var fullRebuild bool
@@ -507,12 +508,12 @@ func setupSQLiteSource(duckDB *sql.DB, dbPath string) (cleanup func(), err error
507508
}
508509

509510
// CSV fallback: export SQLite tables to CSV, create DuckDB views.
510-
// Use the database's parent directory for temp files instead of the
511-
// system temp dir, which can have restricted permissions on Windows
512-
// (e.g. for downloaded executables).
513-
tmpDir, err := os.MkdirTemp(filepath.Dir(dbPath), ".cache-tmp-*")
511+
// Prefer the database's parent directory for temp files (avoids
512+
// cross-device moves), but fall back through system temp and
513+
// ~/.msgvault/tmp/ for read-only or restricted environments.
514+
tmpDir, err := config.MkTempDir(".cache-tmp-*", filepath.Dir(dbPath))
514515
if err != nil {
515-
return nil, fmt.Errorf("create temp dir: %w", err)
516+
return nil, err
516517
}
517518

518519
sqliteDB, err := sql.Open("sqlite3", dbPath+"?mode=ro")

internal/config/config.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,47 @@ func (c *Config) ConfigFilePath() string {
155155
return filepath.Join(c.HomeDir, "config.toml")
156156
}
157157

158+
// MkTempDir creates a temporary directory with fallback logic for restricted
159+
// environments (e.g. Windows where %TEMP% may be inaccessible due to
160+
// permissions, antivirus, or group policy).
161+
//
162+
// It tries the following locations in order:
163+
// 1. Each directory in preferredDirs (if any)
164+
// 2. The system default temp directory (os.TempDir())
165+
// 3. A "tmp" subdirectory under the msgvault home directory (~/.msgvault/tmp/)
166+
//
167+
// The first successful location is used. If all locations fail, the error
168+
// from the system temp dir attempt is returned along with the final fallback error.
169+
func MkTempDir(pattern string, preferredDirs ...string) (string, error) {
170+
// Try preferred directories first
171+
for _, base := range preferredDirs {
172+
if base == "" {
173+
continue
174+
}
175+
dir, err := os.MkdirTemp(base, pattern)
176+
if err == nil {
177+
return dir, nil
178+
}
179+
}
180+
181+
// Try system temp dir
182+
dir, sysErr := os.MkdirTemp("", pattern)
183+
if sysErr == nil {
184+
return dir, nil
185+
}
186+
187+
// Fallback: use ~/.msgvault/tmp/
188+
fallbackBase := filepath.Join(DefaultHome(), "tmp")
189+
if err := os.MkdirAll(fallbackBase, 0700); err != nil {
190+
return "", fmt.Errorf("create temp dir: %w (fallback also failed: %v)", sysErr, err)
191+
}
192+
dir, err := os.MkdirTemp(fallbackBase, pattern)
193+
if err != nil {
194+
return "", fmt.Errorf("create temp dir: %w (fallback also failed: %v)", sysErr, err)
195+
}
196+
return dir, nil
197+
}
198+
158199
// resolveRelative makes a relative path absolute by joining it with base.
159200
// Absolute paths and empty strings are returned unchanged.
160201
func resolveRelative(path, base string) string {

internal/config/config_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,105 @@ func TestDefaultHomeExpandsTilde(t *testing.T) {
344344
}
345345
}
346346

347+
func TestMkTempDir(t *testing.T) {
348+
t.Run("uses system temp when no preferred dirs", func(t *testing.T) {
349+
dir, err := MkTempDir("test-*")
350+
if err != nil {
351+
t.Fatalf("MkTempDir failed: %v", err)
352+
}
353+
defer os.RemoveAll(dir)
354+
355+
if _, err := os.Stat(dir); err != nil {
356+
t.Errorf("temp dir does not exist: %v", err)
357+
}
358+
})
359+
360+
t.Run("uses preferred dir when available", func(t *testing.T) {
361+
preferred := t.TempDir()
362+
dir, err := MkTempDir("test-*", preferred)
363+
if err != nil {
364+
t.Fatalf("MkTempDir failed: %v", err)
365+
}
366+
defer os.RemoveAll(dir)
367+
368+
if !strings.HasPrefix(dir, preferred) {
369+
t.Errorf("temp dir %q not under preferred %q", dir, preferred)
370+
}
371+
})
372+
373+
t.Run("skips empty preferred dir strings", func(t *testing.T) {
374+
dir, err := MkTempDir("test-*", "")
375+
if err != nil {
376+
t.Fatalf("MkTempDir failed: %v", err)
377+
}
378+
defer os.RemoveAll(dir)
379+
380+
// Should have used system temp, not errored
381+
if _, err := os.Stat(dir); err != nil {
382+
t.Errorf("temp dir does not exist: %v", err)
383+
}
384+
})
385+
386+
t.Run("falls back to system temp when preferred dir is inaccessible", func(t *testing.T) {
387+
dir, err := MkTempDir("test-*", "/nonexistent-dir-that-does-not-exist")
388+
if err != nil {
389+
t.Fatalf("MkTempDir failed: %v", err)
390+
}
391+
defer os.RemoveAll(dir)
392+
393+
// Should have fallen back to system temp
394+
if strings.Contains(dir, "nonexistent") {
395+
t.Errorf("should not have used nonexistent dir, got %q", dir)
396+
}
397+
})
398+
399+
t.Run("falls back to msgvault home when system temp is unavailable", func(t *testing.T) {
400+
if runtime.GOOS == "windows" {
401+
t.Skip("cannot make system temp dir unwritable on Windows")
402+
}
403+
404+
// Create a restricted temp dir so os.MkdirTemp("", ...) fails
405+
restrictedTmp := t.TempDir()
406+
if err := os.Chmod(restrictedTmp, 0o500); err != nil {
407+
t.Fatalf("chmod failed: %v", err)
408+
}
409+
t.Cleanup(func() { _ = os.Chmod(restrictedTmp, 0o700) })
410+
411+
// Probe whether the restriction actually works (root and some ACL
412+
// configurations can still write to 0500 directories).
413+
probe, probeErr := os.MkdirTemp(restrictedTmp, "probe-*")
414+
if probeErr == nil {
415+
os.Remove(probe)
416+
t.Skip("chmod 0500 did not restrict writes (running as root or permissive ACLs)")
417+
}
418+
419+
// Point TMPDIR to the restricted dir and MSGVAULT_HOME to a writable dir
420+
msgvaultHome := t.TempDir()
421+
t.Setenv("TMPDIR", restrictedTmp)
422+
t.Setenv("MSGVAULT_HOME", msgvaultHome)
423+
424+
dir, err := MkTempDir("test-*")
425+
if err != nil {
426+
t.Fatalf("MkTempDir failed: %v", err)
427+
}
428+
defer os.RemoveAll(dir)
429+
430+
expectedBase := filepath.Join(msgvaultHome, "tmp")
431+
if !strings.HasPrefix(dir, expectedBase) {
432+
t.Errorf("temp dir %q not under fallback %q", dir, expectedBase)
433+
}
434+
435+
// Verify the tmp dir was created with restrictive permissions
436+
info, err := os.Stat(expectedBase)
437+
if err != nil {
438+
t.Fatalf("stat fallback dir: %v", err)
439+
}
440+
if perm := info.Mode().Perm(); perm != 0o700 {
441+
t.Errorf("fallback dir permissions = %04o, want 0700", perm)
442+
}
443+
})
444+
}
445+
347446
func TestNewDefaultConfig(t *testing.T) {
348447
// Use a temp directory as MSGVAULT_HOME
349448
tmpDir := t.TempDir()

internal/testutil/archive_helpers.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,32 @@ func CreateTarGz(t *testing.T, path string, entries []ArchiveEntry) {
5656
}
5757
}
5858

59+
// CreateZip creates a zip archive at path containing the given entries.
60+
func CreateZip(t *testing.T, path string, entries []ArchiveEntry) {
61+
t.Helper()
62+
f, err := os.Create(path)
63+
if err != nil {
64+
t.Fatal(err)
65+
}
66+
defer f.Close()
67+
68+
w := zip.NewWriter(f)
69+
for _, e := range entries {
70+
fw, err := w.Create(e.Name)
71+
if err != nil {
72+
t.Fatalf("create zip entry %s: %v", e.Name, err)
73+
}
74+
if len(e.Content) > 0 {
75+
if _, err := fw.Write([]byte(e.Content)); err != nil {
76+
t.Fatalf("write zip entry %s: %v", e.Name, err)
77+
}
78+
}
79+
}
80+
if err := w.Close(); err != nil {
81+
t.Fatalf("close zip writer: %v", err)
82+
}
83+
}
84+
5985
// CreateTempZip creates a zip file in a temporary directory containing the
6086
// provided entries (filename -> content). Returns the path to the zip file.
6187
func CreateTempZip(t *testing.T, entries map[string]string) string {

0 commit comments

Comments
 (0)