package updater import ( "archive/zip" "io/fs" "os" "path/filepath" "strings" "testing" ) func TestDoUpgrade(t *testing.T) { tmpDir := t.TempDir() BundlePath = filepath.Join(tmpDir, "Ollama.app") appContents := filepath.Join(BundlePath, "Contents") appBackupDir = filepath.Join(tmpDir, "backup") appContentsOld := filepath.Join(appBackupDir, "Ollama.app", "Contents") UpdateStageDir = filepath.Join(tmpDir, "updates") UpgradeMarkerFile = filepath.Join(tmpDir, "upgraded") bundle := filepath.Join(UpdateStageDir, "foo", "ollama-darwin.zip") err := os.MkdirAll(filepath.Join(appContents, "MacOS"), 0o755) if err != nil { t.Fatal("failed to create empty dirs") } err = os.MkdirAll(filepath.Join(BundlePath, "Contents", "Resources"), 0o755) if err != nil { t.Fatal("failed to create empty dirs") } err = os.MkdirAll(filepath.Dir(bundle), 0o755) if err != nil { t.Fatal("failed to create empty dirs") } // No update file, simple failure scenario if err := DoUpgrade(false); err == nil { t.Fatal("expected failure without download") } else if !strings.Contains(err.Error(), "failed to lookup downloads") { t.Fatalf("unexpected error: %s", err.Error()) } // Start with an unreadable zip file if err := os.WriteFile(bundle, []byte{0x4b, 0x50, 0x40, 0x03, 0x00, 0x0a, 0x00}, 0o755); err != nil { t.Fatalf("failed to create intentionally corrupt zip file: %s", err) } if err := DoUpgrade(false); err == nil { t.Fatal("expected failure with corrupt zip file") } else if !strings.Contains(err.Error(), "unable to open upgrade bundle") { t.Fatalf("unexpected error with corrupt zip file: %s", err) } // Generate valid (partial) zip file for remaining scenarios if err := zipCreationHelper(bundle, []testPayload{ { Name: "Ollama.app/Contents/MacOS/Ollama", Body: []byte("would be app binary"), }, { Name: "Ollama.app/Contents/Resources/ollama", Body: []byte("would be the cli"), }, { Name: "Ollama.app/Contents/Resources/dummy", Body: []byte("./ollama"), Mode: os.ModeSymlink, }, }); err != nil { t.Fatal(err) } // Permission failure on rename if err := os.Chmod(BundlePath, 0o500); err != nil { t.Fatal("failed to remove write permission") } if err := DoUpgrade(false); err == nil { t.Fatal("expected failure with no permission to rename Contents") } else if !strings.Contains(err.Error(), "permission problems") { t.Fatalf("unexpected error with permission failure: %s", err) } if err := os.Chmod(BundlePath, 0o755); err != nil { t.Fatal("failed to restore write permission") } // Prior failed upgrade if err := os.MkdirAll(appContentsOld, 0o755); err != nil { t.Fatal("failed to create empty dirs") } if err := DoUpgrade(false); err == nil { t.Fatal("expected failure with old contents existing") } else if !strings.Contains(err.Error(), "prior upgrade failed") { t.Fatalf("unexpected error with old contents: %s", err) } if err := os.RemoveAll(appBackupDir); err != nil { t.Fatal("failed to cleanup dir") } // TODO - a failure mode where we revert the backup // Happy path if err := DoUpgrade(false); err != nil { t.Fatalf("unexpected error with clean setup: %s", err) } if _, err := os.Stat(appContentsOld); err != nil { t.Fatalf("missing %s", appContentsOld) } if _, err := os.Stat(UpgradeMarkerFile); err != nil { t.Fatalf("missing marker %s", UpgradeMarkerFile) } if _, err := os.Stat(filepath.Join(BundlePath, "Contents", "MacOS", "Ollama")); err != nil { t.Fatalf("missing new App") } if _, err := os.Stat(filepath.Join(BundlePath, "Contents", "Resources", "ollama")); err != nil { t.Fatalf("missing new cli") } // Cleanup before next attempt if err := DoPostUpgradeCleanup(); err != nil { t.Fatal("failed to cleanup dir") } err = os.MkdirAll(filepath.Dir(bundle), 0o755) if err != nil { t.Fatal("failed to create empty dirs") } // Zip file with one corrupt file within to trigger a rollback if err := os.WriteFile(bundle, corruptZipData, 0o755); err != nil { t.Fatalf("failed to create intentionally corrupt zip file: %s", err) } if err := DoUpgrade(false); err == nil { t.Fatal("expected failure with corrupt zip file") } else if !strings.Contains(err.Error(), "failed to open bundle file") { t.Fatalf("unexpected error with corrupt zip file: %s", err) } // Make sure things were restored on partial failure if _, err := os.Stat(appContents); err != nil { t.Fatalf("missing %s", appContents) } if _, err := os.Stat(appContentsOld); err == nil { t.Fatal("old contents still exists") } if _, err := os.Stat(filepath.Join(BundlePath, "Contents", "MacOS", "Ollama")); err != nil { t.Fatalf("missing old App") } if _, err := os.Stat(filepath.Join(BundlePath, "Contents", "Resources", "ollama")); err != nil { t.Fatalf("missing old cli") } } func TestDoUpgradeAtStartup(t *testing.T) { tmpDir := t.TempDir() BundlePath = filepath.Join(tmpDir, "Ollama.app") appBackupDir = filepath.Join(tmpDir, "backup") UpdateStageDir = filepath.Join(tmpDir, "updates") UpgradeMarkerFile = filepath.Join(tmpDir, "upgraded") bundle := filepath.Join(UpdateStageDir, "foo", "ollama-darwin.zip") if err := DoUpgradeAtStartup(); err == nil { t.Fatal("expected failure without download") } else if !strings.Contains(err.Error(), "failed to lookup downloads") { t.Fatalf("unexpected error: %s", err.Error()) } if err := os.MkdirAll(filepath.Dir(bundle), 0o755); err != nil { t.Fatal("failed to create empty dirs") } if err := zipCreationHelper(bundle, []testPayload{ { Name: "Ollama.app/Contents/MacOS/Ollama", Body: []byte("would be app binary"), }, { Name: "Ollama.app/Contents/Resources/ollama", Body: []byte("would be the cli"), }, { Name: "Ollama.app/Contents/Resources/dummy", Body: []byte("./ollama"), Mode: os.ModeSymlink, }, }); err != nil { t.Fatal(err) } if err := DoUpgradeAtStartup(); err != nil { t.Fatalf("unexpected error with verification failure: %s", err) } if _, err := os.Stat(bundle); err == nil { t.Fatalf("unverified bundle still exists %s", bundle) } } func TestVerifyDownloadFailures(t *testing.T) { tmpDir := t.TempDir() BundlePath = filepath.Join(tmpDir, "Ollama.app") UpdateStageDir = filepath.Join(tmpDir, "staging") bundle := filepath.Join(UpdateStageDir, "foo", "ollama-darwin.zip") if err := os.MkdirAll(filepath.Dir(bundle), 0o755); err != nil { t.Fatal("failed to create empty dirs") } tests := []struct { n string in []testPayload expected string }{ {"breakout", []testPayload{ { Name: "Ollama.app/", Body: []byte{}, }, { Name: "Ollama.app/Resources/ollama", Body: []byte("cli payload here"), }, { Name: "Ollama.app/Contents/MacOS/Ollama", Body: []byte("../../../../breakout"), Mode: os.ModeSymlink, }, }, "bundle contains link outside"}, {"absolute", []testPayload{{ Name: "Ollama.app/Contents/MacOS/Ollama", Body: []byte("/etc/foo"), Mode: os.ModeSymlink, }}, "bundle contains absolute"}, {"missing", []testPayload{{ Name: "Ollama.app/Contents/MacOS/Ollama", Body: []byte("../nothere"), Mode: os.ModeSymlink, }}, "no such file or directory"}, {"unsigned", []testPayload{{ Name: "Ollama.app/Contents/MacOS/Ollama", Body: []byte{0xfa, 0xcf, 0xfe, 0xed, 0x00, 0x0c, 0x01, 0x00}, }}, "signature verification failed"}, } for _, tt := range tests { t.Run(tt.n, func(t *testing.T) { _ = os.Remove(bundle) if err := zipCreationHelper(bundle, tt.in); err != nil { t.Fatal(err) } err := VerifyDownload() if err == nil || !strings.Contains(err.Error(), tt.expected) { t.Fatalf("expected \"%s\" got %s", tt.expected, err) } }) } } // One file has been corrupted to cause a checksum mismatch var corruptZipData = []byte{0x50, 0x4b, 0x3, 0x4, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0xed, 0x7e, 0x8f, 0x59, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xb, 0x0, 0x1c, 0x0, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x2e, 0x61, 0x70, 0x70, 0x2f, 0x55, 0x54, 0x9, 0x0, 0x3, 0x6d, 0x6c, 0x5f, 0x67, 0x6e, 0x6c, 0x5f, 0x67, 0x75, 0x78, 0xb, 0x0, 0x1, 0x4, 0xf5, 0x1, 0x0, 0x0, 0x4, 0x14, 0x0, 0x0, 0x0, 0x50, 0x4b, 0x3, 0x4, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0xd8, 0x7e, 0x8f, 0x59, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x14, 0x0, 0x1c, 0x0, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x2e, 0x61, 0x70, 0x70, 0x2f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x55, 0x54, 0x9, 0x0, 0x3, 0x48, 0x6c, 0x5f, 0x67, 0x58, 0x6c, 0x5f, 0x67, 0x75, 0x78, 0xb, 0x0, 0x1, 0x4, 0xf5, 0x1, 0x0, 0x0, 0x4, 0x14, 0x0, 0x0, 0x0, 0x50, 0x4b, 0x3, 0x4, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0xe3, 0x7e, 0x8f, 0x59, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1a, 0x0, 0x1c, 0x0, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x2e, 0x61, 0x70, 0x70, 0x2f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x4d, 0x61, 0x63, 0x4f, 0x53, 0x2f, 0x55, 0x54, 0x9, 0x0, 0x3, 0x59, 0x6c, 0x5f, 0x67, 0x9f, 0x6c, 0x5f, 0x67, 0x75, 0x78, 0xb, 0x0, 0x1, 0x4, 0xf5, 0x1, 0x0, 0x0, 0x4, 0x14, 0x0, 0x0, 0x0, 0x50, 0x4b, 0x3, 0x4, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0xe3, 0x7e, 0x8f, 0x59, 0xe3, 0x6, 0x15, 0x70, 0x14, 0x0, 0x0, 0x0, 0x14, 0x0, 0x0, 0x0, 0x20, 0x0, 0x1c, 0x0, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x2e, 0x61, 0x70, 0x70, 0x2f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x4d, 0x61, 0x63, 0x4f, 0x53, 0x2f, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x55, 0x54, 0x9, 0x0, 0x3, 0x59, 0x6c, 0x5f, 0x67, 0x83, 0x6c, 0x5f, 0x67, 0x75, 0x78, 0xb, 0x0, 0x1, 0x4, 0xf5, 0x1, 0x0, 0x0, 0x4, 0x14, 0x0, 0x0, 0x0, 0x43, 0x4f, 0x52, 0x52, 0x55, 0x50, 0x54, 0xa, 0x50, 0x4b, 0x3, 0x4, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0xe9, 0x7e, 0x8f, 0x59, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1e, 0x0, 0x1c, 0x0, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x2e, 0x61, 0x70, 0x70, 0x2f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2f, 0x55, 0x54, 0x9, 0x0, 0x3, 0x66, 0x6c, 0x5f, 0x67, 0x83, 0x6c, 0x5f, 0x67, 0x75, 0x78, 0xb, 0x0, 0x1, 0x4, 0xf5, 0x1, 0x0, 0x0, 0x4, 0x14, 0x0, 0x0, 0x0, 0x50, 0x4b, 0x3, 0x4, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0xe9, 0x7e, 0x8f, 0x59, 0x19, 0xa5, 0x62, 0xf7, 0x11, 0x0, 0x0, 0x0, 0x11, 0x0, 0x0, 0x0, 0x24, 0x0, 0x1c, 0x0, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x2e, 0x61, 0x70, 0x70, 0x2f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2f, 0x6f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x55, 0x54, 0x9, 0x0, 0x3, 0x66, 0x6c, 0x5f, 0x67, 0x66, 0x6c, 0x5f, 0x67, 0x75, 0x78, 0xb, 0x0, 0x1, 0x4, 0xf5, 0x1, 0x0, 0x0, 0x4, 0x14, 0x0, 0x0, 0x0, 0x77, 0x6f, 0x75, 0x6c, 0x64, 0x20, 0x62, 0x65, 0x20, 0x74, 0x68, 0x65, 0x20, 0x63, 0x6c, 0x69, 0xa, 0x50, 0x4b, 0x1, 0x2, 0x1e, 0x3, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0xed, 0x7e, 0x8f, 0x59, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xb, 0x0, 0x18, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x10, 0x0, 0xed, 0x41, 0x0, 0x0, 0x0, 0x0, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x2e, 0x61, 0x70, 0x70, 0x2f, 0x55, 0x54, 0x5, 0x0, 0x3, 0x6d, 0x6c, 0x5f, 0x67, 0x75, 0x78, 0xb, 0x0, 0x1, 0x4, 0xf5, 0x1, 0x0, 0x0, 0x4, 0x14, 0x0, 0x0, 0x0, 0x50, 0x4b, 0x1, 0x2, 0x1e, 0x3, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0xd8, 0x7e, 0x8f, 0x59, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x14, 0x0, 0x18, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x10, 0x0, 0xed, 0x41, 0x45, 0x0, 0x0, 0x0, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x2e, 0x61, 0x70, 0x70, 0x2f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x55, 0x54, 0x5, 0x0, 0x3, 0x48, 0x6c, 0x5f, 0x67, 0x75, 0x78, 0xb, 0x0, 0x1, 0x4, 0xf5, 0x1, 0x0, 0x0, 0x4, 0x14, 0x0, 0x0, 0x0, 0x50, 0x4b, 0x1, 0x2, 0x1e, 0x3, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0xe3, 0x7e, 0x8f, 0x59, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1a, 0x0, 0x18, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x10, 0x0, 0xed, 0x41, 0x93, 0x0, 0x0, 0x0, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x2e, 0x61, 0x70, 0x70, 0x2f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x4d, 0x61, 0x63, 0x4f, 0x53, 0x2f, 0x55, 0x54, 0x5, 0x0, 0x3, 0x59, 0x6c, 0x5f, 0x67, 0x75, 0x78, 0xb, 0x0, 0x1, 0x4, 0xf5, 0x1, 0x0, 0x0, 0x4, 0x14, 0x0, 0x0, 0x0, 0x50, 0x4b, 0x1, 0x2, 0x1e, 0x3, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0xe3, 0x7e, 0x8f, 0x59, 0xe3, 0x6, 0x15, 0x70, 0x14, 0x0, 0x0, 0x0, 0x14, 0x0, 0x0, 0x0, 0x20, 0x0, 0x18, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xa4, 0x81, 0xe7, 0x0, 0x0, 0x0, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x2e, 0x61, 0x70, 0x70, 0x2f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x4d, 0x61, 0x63, 0x4f, 0x53, 0x2f, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x55, 0x54, 0x5, 0x0, 0x3, 0x59, 0x6c, 0x5f, 0x67, 0x75, 0x78, 0xb, 0x0, 0x1, 0x4, 0xf5, 0x1, 0x0, 0x0, 0x4, 0x14, 0x0, 0x0, 0x0, 0x50, 0x4b, 0x1, 0x2, 0x1e, 0x3, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0xe9, 0x7e, 0x8f, 0x59, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1e, 0x0, 0x18, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x10, 0x0, 0xed, 0x41, 0x55, 0x1, 0x0, 0x0, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x2e, 0x61, 0x70, 0x70, 0x2f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2f, 0x55, 0x54, 0x5, 0x0, 0x3, 0x66, 0x6c, 0x5f, 0x67, 0x75, 0x78, 0xb, 0x0, 0x1, 0x4, 0xf5, 0x1, 0x0, 0x0, 0x4, 0x14, 0x0, 0x0, 0x0, 0x50, 0x4b, 0x1, 0x2, 0x1e, 0x3, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0xe9, 0x7e, 0x8f, 0x59, 0x19, 0xa5, 0x62, 0xf7, 0x11, 0x0, 0x0, 0x0, 0x11, 0x0, 0x0, 0x0, 0x24, 0x0, 0x18, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xa4, 0x81, 0xad, 0x1, 0x0, 0x0, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x2e, 0x61, 0x70, 0x70, 0x2f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2f, 0x6f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x55, 0x54, 0x5, 0x0, 0x3, 0x66, 0x6c, 0x5f, 0x67, 0x75, 0x78, 0xb, 0x0, 0x1, 0x4, 0xf5, 0x1, 0x0, 0x0, 0x4, 0x14, 0x0, 0x0, 0x0, 0x50, 0x4b, 0x5, 0x6, 0x0, 0x0, 0x0, 0x0, 0x6, 0x0, 0x6, 0x0, 0x3f, 0x2, 0x0, 0x0, 0x1c, 0x2, 0x0, 0x0, 0x0, 0x0} type testPayload struct { Name string Body []byte Mode fs.FileMode } func zipCreationHelper(filename string, files []testPayload) error { fd, err := os.Create(filename) if err != nil { return err } w := zip.NewWriter(fd) for _, file := range files { fh := &zip.FileHeader{ Name: file.Name, Flags: 0, } if file.Mode != 0 { fh.SetMode(file.Mode) } f, err := w.CreateHeader(fh) if err != nil { return err } _, err = f.Write(file.Body) if err != nil { return err } } return w.Close() } func TestAlreadyMoved(t *testing.T) { oldPath := SystemWidePath defer func() { SystemWidePath = oldPath }() exe, err := os.Executable() if err != nil { t.Fatal("failed to find executable path") } tmpDir := t.TempDir() testApp := filepath.Join(tmpDir, "Ollama.app") err = os.MkdirAll(filepath.Join(testApp, "Contents", "MacOS"), 0o755) if err != nil { t.Fatal("failed to create Contents dir") } SystemWidePath = testApp testBinary := filepath.Join(testApp, "Contents", "MacOS", "Ollama") if err := os.Symlink(exe, testBinary); err != nil { t.Fatalf("failed to create symlink to executable: %s", err) } bundle := alreadyMoved() if bundle != testApp { t.Fatalf("expected %s, got %s", testApp, bundle) } // "Keep scenario" testApp = filepath.Join(tmpDir, "Ollama 2.app") err = os.MkdirAll(filepath.Join(testApp, "Contents", "MacOS"), 0o755) if err != nil { t.Fatal("failed to create Contents dir") } testBinary = filepath.Join(testApp, "Contents", "MacOS", "Ollama") if err := os.Symlink(exe, testBinary); err != nil { t.Fatalf("failed to create symlink to executable: %s", err) } bundle = alreadyMoved() if bundle != testApp { t.Fatalf("expected %s, got %s", testApp, bundle) } }