From 6f73ed7196587307ee19fd2d6ac18bb55a0312b6 Mon Sep 17 00:00:00 2001 From: naveensrinivasan <172697+naveensrinivasan@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:44:40 -0500 Subject: [PATCH] Fixed changes for Pycharm --- internal/cli/cli.go | 12 +- internal/config/config.go | 4 +- internal/config/config_test.go | 13 + internal/runtimes/intellij/intellij.go | 437 +----------------- internal/runtimes/jetbrains/adapter.go | 474 ++++++++++++++++++++ internal/runtimes/jetbrains/adapter_test.go | 25 ++ internal/runtimes/pycharm/pycharm.go | 35 ++ internal/runtimes/pycharm/pycharm_test.go | 102 +++++ runtime-registry.yaml | 54 +++ 9 files changed, 735 insertions(+), 421 deletions(-) create mode 100644 internal/runtimes/jetbrains/adapter.go create mode 100644 internal/runtimes/jetbrains/adapter_test.go create mode 100644 internal/runtimes/pycharm/pycharm.go create mode 100644 internal/runtimes/pycharm/pycharm_test.go diff --git a/internal/cli/cli.go b/internal/cli/cli.go index d6c8826..915e8f1 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -26,6 +26,7 @@ import ( "github.com/clean-dependency-project/cdprun/internal/runtime" intellijAdapter "github.com/clean-dependency-project/cdprun/internal/runtimes/intellij" nodejsAdapter "github.com/clean-dependency-project/cdprun/internal/runtimes/nodejs" + pycharmAdapter "github.com/clean-dependency-project/cdprun/internal/runtimes/pycharm" pythonAdapter "github.com/clean-dependency-project/cdprun/internal/runtimes/python" temurinAdapter "github.com/clean-dependency-project/cdprun/internal/runtimes/temurin" tomcatAdapter "github.com/clean-dependency-project/cdprun/internal/runtimes/tomcat" @@ -95,7 +96,7 @@ func NewApp() *cli.App { &cli.StringFlag{ Name: "runtime", Aliases: []string{"r"}, - Usage: "runtime name (nodejs, python, tomcat, yarn, temurin, vscode, intellij_idea_ultimate). If not specified, downloads all enabled runtimes from config", + Usage: "runtime name (nodejs, python, tomcat, yarn, temurin, vscode, intellij_idea_ultimate, pycharm_professional). If not specified, downloads all enabled runtimes from config", }, &cli.StringFlag{ Name: "version", @@ -643,6 +644,15 @@ func initializeManager(configPath string, db *storage.DB, stdout, stderr *slog.L "runtime", runtimeName, "endoflife_product", runtimeConfig.EndOfLifeProduct, "policy_file", runtimeConfig.PolicyFile) + case "pycharm_professional": + adapter := pycharmAdapter.NewAdapterWithConfig(&runtimeConfig, &cfg.Config, stdout, stderr) + if err := registry.Register("pycharm_professional", adapter); err != nil { + return nil, nil, fmt.Errorf("failed to register pycharm adapter: %w", err) + } + stdout.Info("registered runtime adapter", + "runtime", runtimeName, + "endoflife_product", runtimeConfig.EndOfLifeProduct, + "policy_file", runtimeConfig.PolicyFile) default: stderr.Warn("unsupported runtime", "runtime", runtimeName) } diff --git a/internal/config/config.go b/internal/config/config.go index c82bd68..3fac3bc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -289,7 +289,7 @@ func (c *Config) Validate() error { // RuntimeSkipsPolicyFile reports runtimes that resolve versions from an upstream API and do not use policies/*.json. func RuntimeSkipsPolicyFile(name string) bool { switch strings.ToLower(strings.TrimSpace(name)) { - case "vscode", "intellij_idea_ultimate": + case "vscode", "intellij_idea_ultimate", "pycharm_professional": return true default: return false @@ -299,7 +299,7 @@ func RuntimeSkipsPolicyFile(name string) bool { // RuntimeSkipsEndOfLifeProduct reports runtimes not indexed on endoflife.date; endoflife_product may be omitted. func RuntimeSkipsEndOfLifeProduct(name string) bool { switch strings.ToLower(strings.TrimSpace(name)) { - case "intellij_idea_ultimate": + case "intellij_idea_ultimate", "pycharm_professional": return true default: return false diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 15961f6..f8ae273 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -351,6 +351,19 @@ func TestRuntime_Validate(t *testing.T) { runtimeName: "intellij_idea_ultimate", expectError: false, }, + { + name: "pycharm_professional skips endoflife.date and policy", + runtime: Runtime{ + Enabled: true, + EndOfLifeProduct: "", + PolicyFile: "", + Verification: Verification{ + Enabled: false, + }, + }, + runtimeName: "pycharm_professional", + expectError: false, + }, } for _, tt := range tests { diff --git a/internal/runtimes/intellij/intellij.go b/internal/runtimes/intellij/intellij.go index 4b86068..2f46c18 100644 --- a/internal/runtimes/intellij/intellij.go +++ b/internal/runtimes/intellij/intellij.go @@ -1,449 +1,50 @@ -// Package intellij provides an IntelliJ IDEA runtime adapter using JetBrains product releases JSON. -// Versions come only from data.services.jetbrains.com — there is no endoflife.date product for this IDE. +// Package intellij provides an IntelliJ IDEA runtime adapter using shared JetBrains runtime logic. package intellij import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" "log/slog" - "net/http" - neturl "net/url" - "os" - "path" - "path/filepath" - "strings" - "sync" - "time" "github.com/clean-dependency-project/cdprun/internal/config" - "github.com/clean-dependency-project/cdprun/internal/endoflife" - "github.com/clean-dependency-project/cdprun/internal/platform" "github.com/clean-dependency-project/cdprun/internal/runtime" - "github.com/clean-dependency-project/cdprun/internal/version" + "github.com/clean-dependency-project/cdprun/internal/runtimes/jetbrains" ) const ( // IntelliJUltimateRuntime is the registry key for IntelliJ IDEA Ultimate (IIU). IntelliJUltimateRuntime = "intellij_idea_ultimate" - defaultReleasesURL = "https://data.services.jetbrains.com/products/releases" defaultProductCode = "IIU" ) -type downloadMeta struct { - Link string `json:"link"` - ChecksumLink string `json:"checksumLink"` -} - -type releaseEntry struct { - Version string `json:"version"` - Downloads map[string]downloadMeta `json:"downloads"` -} - -// Adapter implements runtime.RuntimeProvider for JetBrains IntelliJ IDEA downloads. +// Adapter is a thin IntelliJ wrapper around the shared JetBrains adapter. type Adapter struct { - config *config.Runtime - globalConfig *config.GlobalConfig - downloader *runtime.ConcurrentDownloader - stdout *slog.Logger - stderr *slog.Logger - httpClient *http.Client - - mu sync.RWMutex - expectedSHA256ByClass map[string]string + *jetbrains.Adapter } // NewAdapterWithConfig builds an IntelliJ adapter (Ultimate by default when jetbrains_code is empty). func NewAdapterWithConfig(cfg *config.Runtime, globalCfg *config.GlobalConfig, stdout, stderr *slog.Logger) runtime.RuntimeProvider { - if stdout == nil { - stdout = slog.Default() - } - if stderr == nil { - stderr = slog.Default() - } - - timeout := 30 * time.Second - if globalCfg != nil { - timeout = globalCfg.GetDownloadTimeout() - } - return &Adapter{ - config: cfg, - globalConfig: globalCfg, - downloader: runtime.NewConcurrentDownloader(4, timeout, stdout, stderr), - stdout: stdout, - stderr: stderr, - httpClient: &http.Client{Timeout: timeout}, - expectedSHA256ByClass: make(map[string]string), - } -} - -func (a *Adapter) GetName() string { - return IntelliJUltimateRuntime -} - -// GetEndOfLifeProduct returns empty: IntelliJ is not tracked on endoflife.date. -func (a *Adapter) GetEndOfLifeProduct() string { - return "" -} - -func (a *Adapter) productCode() string { - if a.config != nil && strings.TrimSpace(a.config.Download.JetBrainsCode) != "" { - return strings.TrimSpace(a.config.Download.JetBrainsCode) - } - return defaultProductCode -} - -func (a *Adapter) releaseType() string { - if a.config != nil && strings.TrimSpace(a.config.Download.JetBrainsType) != "" { - return strings.TrimSpace(a.config.Download.JetBrainsType) - } - return "release" -} - -func (a *Adapter) releasesBaseURL() string { - if a.config != nil && strings.TrimSpace(a.config.Download.BaseURL) != "" { - return strings.TrimRight(strings.TrimSpace(a.config.Download.BaseURL), "/") - } - return defaultReleasesURL -} - -func (a *Adapter) metadataUserAgent() string { - if a.config != nil && strings.TrimSpace(a.config.Download.UserAgent) != "" { - return strings.TrimSpace(a.config.Download.UserAgent) - } - return "cdprun/1.0 (IntelliJ)" -} - -func (a *Adapter) GetSupportedPlatforms() []platform.Platform { - if a.config != nil { - return a.config.GetConfiguredPlatforms() - } - return []platform.Platform{ - {OS: "windows", Arch: "x64", FileExt: "exe", DownloadName: "windows", Classifier: "windows-x64"}, - {OS: "windows", Arch: "aarch64", FileExt: "exe", DownloadName: "windowsARM64", Classifier: "windows-aarch64"}, - {OS: "mac", Arch: "aarch64", FileExt: "dmg", DownloadName: "macM1", Classifier: "mac-aarch64"}, - } -} - -func (a *Adapter) ListVersions(ctx context.Context) ([]endoflife.VersionInfo, error) { - rel, err := a.fetchLatestRelease(ctx, "") - if err != nil { - return nil, err - } - return []endoflife.VersionInfo{a.releaseToVersionInfo(rel)}, nil -} - -func (a *Adapter) releaseToVersionInfo(rel *releaseEntry) endoflife.VersionInfo { - v := strings.TrimSpace(rel.Version) - return endoflife.VersionInfo{ - Version: v, - LatestPatch: v, - IsSupported: true, - IsRecommended: true, - IsLTS: false, - IsEOL: false, - IsEOAS: false, - IsMaintained: true, - RuntimeName: IntelliJUltimateRuntime, - VersionPattern: version.PatternMajor, - } -} - -func (a *Adapter) GetLatestVersion(ctx context.Context, opts runtime.VersionOptions) (endoflife.VersionInfo, error) { - if opts.ExactMatch && strings.TrimSpace(opts.VersionPattern) != "" { - v := strings.TrimSpace(opts.VersionPattern) - return endoflife.VersionInfo{ - Version: v, - LatestPatch: v, - IsSupported: true, - IsRecommended: true, - IsLTS: false, - IsEOL: false, - IsEOAS: false, - IsMaintained: true, - RuntimeName: IntelliJUltimateRuntime, - VersionPattern: version.PatternMajor, - }, nil - } - - pattern := strings.TrimSpace(opts.VersionPattern) - var major string - if isTwoSegmentMajor(pattern) { - major = pattern - } - - rel, err := a.fetchLatestRelease(ctx, major) - if err != nil { - return endoflife.VersionInfo{}, err - } - vi := a.releaseToVersionInfo(rel) - return vi, nil -} - -func (a *Adapter) CreateDownloadTasks(versionInfo endoflife.VersionInfo, platforms []platform.Platform, outputDir string) ([]runtime.DownloadTask, error) { - major := majorVersionFromFullVersion(strings.TrimSpace(versionInfo.LatestPatch)) - if major == "" { - major = majorVersionFromFullVersion(strings.TrimSpace(versionInfo.Version)) - } - - rel, err := a.fetchLatestRelease(context.Background(), major) - if err != nil { - return nil, err - } - - userAgent := a.metadataUserAgent() - - tasks := make([]runtime.DownloadTask, 0, len(platforms)) - for _, plat := range platforms { - key := strings.TrimSpace(plat.DownloadName) - if key == "" { - return nil, fmt.Errorf("empty download_name for platform %s", plat.Classifier) - } - artifact, ok := rel.Downloads[key] - if !ok { - return nil, fmt.Errorf("no %s download for JetBrains key %q", plat.Classifier, key) - } - if strings.TrimSpace(artifact.Link) == "" { - return nil, fmt.Errorf("empty download link for %s (key %q)", plat.Classifier, key) - } - if strings.TrimSpace(artifact.ChecksumLink) == "" { - return nil, fmt.Errorf("empty checksum link for %s (key %q)", plat.Classifier, key) - } - - sum, err := a.fetchChecksumHex(context.Background(), artifact.ChecksumLink, userAgent) - if err != nil { - return nil, fmt.Errorf("checksum for %s: %w", plat.Classifier, err) - } - - ver := strings.TrimSpace(rel.Version) - if ver == "" { - ver = strings.TrimSpace(versionInfo.LatestPatch) - } - if ver == "" { - ver = strings.TrimSpace(versionInfo.Version) - } - if ver == "" { - return nil, fmt.Errorf("unable to determine IntelliJ version for %s", plat.Classifier) - } - - ext := extensionFromDownloadURL(artifact.Link) - fileName := fmt.Sprintf("intellij-idea-%s-%s%s", ver, plat.Classifier, ext) - outputPath := filepath.Join(outputDir, plat.Classifier, fileName) - - a.setExpectedSHA256(plat.Classifier, sum) - - tasks = append(tasks, runtime.DownloadTask{ - URL: artifact.Link, - OutputPath: outputPath, - Platform: plat, - Runtime: IntelliJUltimateRuntime, - Version: ver, - FileType: "main", - Headers: map[string]string{ - "User-Agent": userAgent, - "Accept": "*/*", - }, - }) - } - - return tasks, nil -} - -func (a *Adapter) ProcessDownloads(ctx context.Context, tasks []runtime.DownloadTask, concurrency int) ([]runtime.DownloadResult, error) { - timeout := 30 * time.Second - if a.globalConfig != nil { - timeout = a.globalConfig.GetDownloadTimeout() + Adapter: jetbrains.NewAdapterWithConfig(cfg, globalCfg, jetbrains.ProductOptions{ + RuntimeName: IntelliJUltimateRuntime, + DefaultProductCode: defaultProductCode, + DefaultReleaseType: "release", + DefaultReleasesURL: "https://data.services.jetbrains.com/products/releases", + DefaultUserAgent: "cdprun/1.0 (IntelliJ)", + ArtifactPrefix: "intellij-idea", + }, stdout, stderr), } - downloader := runtime.NewConcurrentDownloader(concurrency, timeout, a.stdout, a.stderr) - return downloader.ProcessDownloads(ctx, tasks) -} - -func (a *Adapter) GetVerificationStrategy() runtime.VerificationStrategy { - return &sha256Verifier{adapter: a} -} - -// LoadPolicy is a no-op; IntelliJ versions come only from JetBrains releases JSON. -func (a *Adapter) LoadPolicy(filePath string) ([]endoflife.PolicyVersion, error) { - _ = filePath - return []endoflife.PolicyVersion{}, nil -} - -// ApplyPolicy is a no-op. -func (a *Adapter) ApplyPolicy(versions []endoflife.VersionInfo, policy []endoflife.PolicyVersion) ([]endoflife.VersionInfo, error) { - _ = policy - return versions, nil -} - -func (a *Adapter) GetMaintainedVersions(ctx context.Context) ([]endoflife.VersionInfo, error) { - return a.ListVersions(ctx) -} - -func (a *Adapter) fetchLatestRelease(ctx context.Context, majorVersion string) (*releaseEntry, error) { - base := a.releasesBaseURL() - q := neturl.Values{} - q.Set("code", a.productCode()) - q.Set("latest", "true") - q.Set("type", a.releaseType()) - if strings.TrimSpace(majorVersion) != "" { - q.Set("majorVersion", strings.TrimSpace(majorVersion)) - } - url := base + "?" + q.Encode() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("build jetbrains releases request: %w", err) - } - req.Header.Set("User-Agent", a.metadataUserAgent()) - req.Header.Set("Accept", "application/json") - - resp, err := a.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("jetbrains releases request: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("jetbrains releases status %d", resp.StatusCode) - } - - var decoded map[string][]releaseEntry - if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { - return nil, fmt.Errorf("decode jetbrains releases: %w", err) - } - - code := a.productCode() - list := decoded[code] - if len(list) == 0 { - return nil, fmt.Errorf("jetbrains releases: empty list for code %q", code) - } - return &list[0], nil -} - -func (a *Adapter) fetchChecksumHex(ctx context.Context, checksumURL, userAgent string) (string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, checksumURL, nil) - if err != nil { - return "", fmt.Errorf("build checksum request: %w", err) - } - req.Header.Set("User-Agent", userAgent) - - resp, err := a.httpClient.Do(req) - if err != nil { - return "", fmt.Errorf("checksum request: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("checksum status %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("read checksum body: %w", err) - } - return parseChecksumLine(string(body)) -} - -func parseChecksumLine(body string) (string, error) { - line := strings.TrimSpace(body) - line = strings.TrimPrefix(line, "\uFEFF") - fields := strings.Fields(line) - if len(fields) < 1 { - return "", fmt.Errorf("empty checksum file") - } - h := strings.ToLower(strings.TrimSpace(fields[0])) - if len(h) != 64 { - return "", fmt.Errorf("invalid sha256 in checksum file (len=%d)", len(h)) - } - return h, nil -} - -func majorVersionFromFullVersion(ver string) string { - ver = strings.TrimSpace(ver) - parts := strings.Split(ver, ".") - if len(parts) >= 2 { - return parts[0] + "." + parts[1] - } - return "" -} - -func isTwoSegmentMajor(s string) bool { - parts := strings.Split(strings.TrimSpace(s), ".") - return len(parts) == 2 && parts[0] != "" && parts[1] != "" -} - -func extensionFromDownloadURL(downloadURL string) string { - parsed, err := neturl.Parse(downloadURL) - if err != nil { - return ".bin" - } - ext := path.Ext(parsed.Path) - if strings.TrimSpace(ext) == "" { - return ".bin" - } - return ext } +// setExpectedSHA256 remains for package-level tests. func (a *Adapter) setExpectedSHA256(classifier, hash string) { - a.mu.Lock() - defer a.mu.Unlock() - a.expectedSHA256ByClass[classifier] = strings.ToLower(strings.TrimSpace(hash)) + a.SetExpectedSHA256(classifier, hash) } +// expectedSHA256 remains for package-level tests. func (a *Adapter) expectedSHA256(classifier string) string { - a.mu.RLock() - defer a.mu.RUnlock() - return a.expectedSHA256ByClass[classifier] + return a.ExpectedSHA256(classifier) } -type sha256Verifier struct { - adapter *Adapter -} - -func (v *sha256Verifier) Verify(ctx context.Context, result runtime.DownloadResult) error { - _ = ctx - expected := v.adapter.expectedSHA256(result.Platform.Classifier) - if expected == "" { - if err := runtime.WriteChecksumAuditRecord(result, "", "", false, "failed", "missing expected sha256 for platform"); err != nil { - return fmt.Errorf("missing expected sha256 for platform %s and failed writing proof files: %w", result.Platform.Classifier, err) - } - return fmt.Errorf("missing expected sha256 for platform %s", result.Platform.Classifier) - } - - file, err := os.Open(result.LocalPath) - if err != nil { - return fmt.Errorf("open downloaded file: %w", err) - } - defer func() { _ = file.Close() }() - - hash := sha256.New() - if _, err := io.Copy(hash, file); err != nil { - if proofErr := runtime.WriteChecksumAuditRecord(result, expected, "", false, "failed", "failed to compute sha256"); proofErr != nil { - return fmt.Errorf("compute sha256 failed: %v; failed writing proof files: %w", err, proofErr) - } - return fmt.Errorf("compute sha256: %w", err) - } - actual := hex.EncodeToString(hash.Sum(nil)) - if actual != expected { - if err := runtime.WriteChecksumAuditRecord(result, expected, actual, false, "failed", "sha256 mismatch"); err != nil { - return fmt.Errorf("sha256 mismatch for %s and failed writing proof files: %w", result.Platform.Classifier, err) - } - return fmt.Errorf("sha256 mismatch for %s: expected %s got %s", result.Platform.Classifier, expected, actual) - } - - if err := runtime.WriteChecksumAuditRecord(result, expected, actual, true, "success", ""); err != nil { - return fmt.Errorf("failed writing proof files: %w", err) - } - return nil -} - -func (v *sha256Verifier) GetType() string { - return "checksum-sha256" -} - -func (v *sha256Verifier) RequiresAdditionalFiles() bool { - return false +// parseChecksumLine remains for package-level tests. +func parseChecksumLine(body string) (string, error) { + return jetbrains.ParseChecksumLine(body) } diff --git a/internal/runtimes/jetbrains/adapter.go b/internal/runtimes/jetbrains/adapter.go new file mode 100644 index 0000000..5ecb14b --- /dev/null +++ b/internal/runtimes/jetbrains/adapter.go @@ -0,0 +1,474 @@ +// Package jetbrains provides shared runtime adapter logic for JetBrains IDE products. +package jetbrains + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + neturl "net/url" + "os" + "path" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/clean-dependency-project/cdprun/internal/config" + "github.com/clean-dependency-project/cdprun/internal/endoflife" + "github.com/clean-dependency-project/cdprun/internal/platform" + "github.com/clean-dependency-project/cdprun/internal/runtime" + "github.com/clean-dependency-project/cdprun/internal/version" +) + +// ProductOptions configures runtime-specific JetBrains metadata. +type ProductOptions struct { + RuntimeName string + DefaultProductCode string + DefaultReleaseType string + DefaultReleasesURL string + DefaultUserAgent string + ArtifactPrefix string +} + +type downloadMeta struct { + Link string `json:"link"` + ChecksumLink string `json:"checksumLink"` +} + +type releaseEntry struct { + Version string `json:"version"` + Downloads map[string]downloadMeta `json:"downloads"` +} + +// Adapter implements runtime.RuntimeProvider for JetBrains downloads. +type Adapter struct { + config *config.Runtime + globalConfig *config.GlobalConfig + downloader *runtime.ConcurrentDownloader + stdout *slog.Logger + stderr *slog.Logger + httpClient *http.Client + opts ProductOptions + + mu sync.RWMutex + expectedSHA256ByClass map[string]string +} + +// NewAdapterWithConfig builds a shared JetBrains adapter with runtime-specific options. +func NewAdapterWithConfig( + cfg *config.Runtime, + globalCfg *config.GlobalConfig, + opts ProductOptions, + stdout, stderr *slog.Logger, +) *Adapter { + if stdout == nil { + stdout = slog.Default() + } + if stderr == nil { + stderr = slog.Default() + } + + timeout := 30 * time.Second + if globalCfg != nil { + timeout = globalCfg.GetDownloadTimeout() + } + + if strings.TrimSpace(opts.DefaultReleaseType) == "" { + opts.DefaultReleaseType = "release" + } + if strings.TrimSpace(opts.DefaultReleasesURL) == "" { + opts.DefaultReleasesURL = "https://data.services.jetbrains.com/products/releases" + } + if strings.TrimSpace(opts.ArtifactPrefix) == "" { + opts.ArtifactPrefix = "jetbrains" + } + + return &Adapter{ + config: cfg, + globalConfig: globalCfg, + downloader: runtime.NewConcurrentDownloader(4, timeout, stdout, stderr), + stdout: stdout, + stderr: stderr, + httpClient: &http.Client{Timeout: timeout}, + opts: opts, + expectedSHA256ByClass: make(map[string]string), + } +} + +func (a *Adapter) GetName() string { + return strings.TrimSpace(a.opts.RuntimeName) +} + +// GetEndOfLifeProduct returns empty: JetBrains desktop IDE products are not tracked on endoflife.date. +func (a *Adapter) GetEndOfLifeProduct() string { + return "" +} + +func (a *Adapter) productCode() string { + if a.config != nil && strings.TrimSpace(a.config.Download.JetBrainsCode) != "" { + return strings.TrimSpace(a.config.Download.JetBrainsCode) + } + return strings.TrimSpace(a.opts.DefaultProductCode) +} + +func (a *Adapter) releaseType() string { + if a.config != nil && strings.TrimSpace(a.config.Download.JetBrainsType) != "" { + return strings.TrimSpace(a.config.Download.JetBrainsType) + } + return strings.TrimSpace(a.opts.DefaultReleaseType) +} + +func (a *Adapter) releasesBaseURL() string { + if a.config != nil && strings.TrimSpace(a.config.Download.BaseURL) != "" { + return strings.TrimRight(strings.TrimSpace(a.config.Download.BaseURL), "/") + } + return strings.TrimRight(strings.TrimSpace(a.opts.DefaultReleasesURL), "/") +} + +func (a *Adapter) metadataUserAgent() string { + if a.config != nil && strings.TrimSpace(a.config.Download.UserAgent) != "" { + return strings.TrimSpace(a.config.Download.UserAgent) + } + return strings.TrimSpace(a.opts.DefaultUserAgent) +} + +func (a *Adapter) GetSupportedPlatforms() []platform.Platform { + if a.config != nil { + return a.config.GetConfiguredPlatforms() + } + return []platform.Platform{ + {OS: "windows", Arch: "x64", FileExt: "exe", DownloadName: "windows", Classifier: "windows-x64"}, + {OS: "windows", Arch: "aarch64", FileExt: "exe", DownloadName: "windowsARM64", Classifier: "windows-aarch64"}, + {OS: "mac", Arch: "aarch64", FileExt: "dmg", DownloadName: "macM1", Classifier: "mac-aarch64"}, + } +} + +func (a *Adapter) ListVersions(ctx context.Context) ([]endoflife.VersionInfo, error) { + rel, err := a.fetchLatestRelease(ctx, "") + if err != nil { + return nil, err + } + return []endoflife.VersionInfo{a.releaseToVersionInfo(rel)}, nil +} + +func (a *Adapter) releaseToVersionInfo(rel *releaseEntry) endoflife.VersionInfo { + v := strings.TrimSpace(rel.Version) + return endoflife.VersionInfo{ + Version: v, + LatestPatch: v, + IsSupported: true, + IsRecommended: true, + IsLTS: false, + IsEOL: false, + IsEOAS: false, + IsMaintained: true, + RuntimeName: a.GetName(), + VersionPattern: version.PatternMajor, + } +} + +func (a *Adapter) GetLatestVersion(ctx context.Context, opts runtime.VersionOptions) (endoflife.VersionInfo, error) { + if opts.ExactMatch && strings.TrimSpace(opts.VersionPattern) != "" { + v := strings.TrimSpace(opts.VersionPattern) + return endoflife.VersionInfo{ + Version: v, + LatestPatch: v, + IsSupported: true, + IsRecommended: true, + IsLTS: false, + IsEOL: false, + IsEOAS: false, + IsMaintained: true, + RuntimeName: a.GetName(), + VersionPattern: version.PatternMajor, + }, nil + } + + pattern := strings.TrimSpace(opts.VersionPattern) + var major string + if isTwoSegmentMajor(pattern) { + major = pattern + } + + rel, err := a.fetchLatestRelease(ctx, major) + if err != nil { + return endoflife.VersionInfo{}, err + } + return a.releaseToVersionInfo(rel), nil +} + +func (a *Adapter) CreateDownloadTasks(versionInfo endoflife.VersionInfo, platforms []platform.Platform, outputDir string) ([]runtime.DownloadTask, error) { + major := majorVersionFromFullVersion(strings.TrimSpace(versionInfo.LatestPatch)) + if major == "" { + major = majorVersionFromFullVersion(strings.TrimSpace(versionInfo.Version)) + } + + rel, err := a.fetchLatestRelease(context.Background(), major) + if err != nil { + return nil, err + } + + userAgent := a.metadataUserAgent() + prefix := strings.TrimSpace(a.opts.ArtifactPrefix) + if prefix == "" { + prefix = "jetbrains" + } + + tasks := make([]runtime.DownloadTask, 0, len(platforms)) + for _, plat := range platforms { + key := strings.TrimSpace(plat.DownloadName) + if key == "" { + return nil, fmt.Errorf("empty download_name for platform %s", plat.Classifier) + } + artifact, ok := rel.Downloads[key] + if !ok { + return nil, fmt.Errorf("no %s download for JetBrains key %q", plat.Classifier, key) + } + if strings.TrimSpace(artifact.Link) == "" { + return nil, fmt.Errorf("empty download link for %s (key %q)", plat.Classifier, key) + } + if strings.TrimSpace(artifact.ChecksumLink) == "" { + return nil, fmt.Errorf("empty checksum link for %s (key %q)", plat.Classifier, key) + } + + sum, err := a.fetchChecksumHex(context.Background(), artifact.ChecksumLink, userAgent) + if err != nil { + return nil, fmt.Errorf("checksum for %s: %w", plat.Classifier, err) + } + + ver := strings.TrimSpace(rel.Version) + if ver == "" { + ver = strings.TrimSpace(versionInfo.LatestPatch) + } + if ver == "" { + ver = strings.TrimSpace(versionInfo.Version) + } + if ver == "" { + return nil, fmt.Errorf("unable to determine %s version for %s", a.GetName(), plat.Classifier) + } + + ext := extensionFromDownloadURL(artifact.Link) + fileName := fmt.Sprintf("%s-%s-%s%s", prefix, ver, plat.Classifier, ext) + outputPath := filepath.Join(outputDir, plat.Classifier, fileName) + + a.SetExpectedSHA256(plat.Classifier, sum) + + tasks = append(tasks, runtime.DownloadTask{ + URL: artifact.Link, + OutputPath: outputPath, + Platform: plat, + Runtime: a.GetName(), + Version: ver, + FileType: "main", + Headers: map[string]string{ + "User-Agent": userAgent, + "Accept": "*/*", + }, + }) + } + + return tasks, nil +} + +func (a *Adapter) ProcessDownloads(ctx context.Context, tasks []runtime.DownloadTask, concurrency int) ([]runtime.DownloadResult, error) { + timeout := 30 * time.Second + if a.globalConfig != nil { + timeout = a.globalConfig.GetDownloadTimeout() + } + downloader := runtime.NewConcurrentDownloader(concurrency, timeout, a.stdout, a.stderr) + return downloader.ProcessDownloads(ctx, tasks) +} + +func (a *Adapter) GetVerificationStrategy() runtime.VerificationStrategy { + return &sha256Verifier{adapter: a} +} + +// LoadPolicy is a no-op; JetBrains versions come only from releases JSON. +func (a *Adapter) LoadPolicy(filePath string) ([]endoflife.PolicyVersion, error) { + _ = filePath + return []endoflife.PolicyVersion{}, nil +} + +// ApplyPolicy is a no-op. +func (a *Adapter) ApplyPolicy(versions []endoflife.VersionInfo, policy []endoflife.PolicyVersion) ([]endoflife.VersionInfo, error) { + _ = policy + return versions, nil +} + +func (a *Adapter) GetMaintainedVersions(ctx context.Context) ([]endoflife.VersionInfo, error) { + return a.ListVersions(ctx) +} + +func (a *Adapter) fetchLatestRelease(ctx context.Context, majorVersion string) (*releaseEntry, error) { + base := a.releasesBaseURL() + q := neturl.Values{} + q.Set("code", a.productCode()) + q.Set("latest", "true") + q.Set("type", a.releaseType()) + if strings.TrimSpace(majorVersion) != "" { + q.Set("majorVersion", strings.TrimSpace(majorVersion)) + } + url := base + "?" + q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("build jetbrains releases request: %w", err) + } + req.Header.Set("User-Agent", a.metadataUserAgent()) + req.Header.Set("Accept", "application/json") + + resp, err := a.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("jetbrains releases request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("jetbrains releases status %d", resp.StatusCode) + } + + var decoded map[string][]releaseEntry + if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { + return nil, fmt.Errorf("decode jetbrains releases: %w", err) + } + + code := a.productCode() + list := decoded[code] + if len(list) == 0 { + return nil, fmt.Errorf("jetbrains releases: empty list for code %q", code) + } + return &list[0], nil +} + +func (a *Adapter) fetchChecksumHex(ctx context.Context, checksumURL, userAgent string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, checksumURL, nil) + if err != nil { + return "", fmt.Errorf("build checksum request: %w", err) + } + req.Header.Set("User-Agent", userAgent) + + resp, err := a.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("checksum request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("checksum status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read checksum body: %w", err) + } + return ParseChecksumLine(string(body)) +} + +// ParseChecksumLine parses the first checksum token from a checksum file line. +func ParseChecksumLine(body string) (string, error) { + line := strings.TrimSpace(body) + line = strings.TrimPrefix(line, "\uFEFF") + fields := strings.Fields(line) + if len(fields) < 1 { + return "", fmt.Errorf("empty checksum file") + } + h := strings.ToLower(strings.TrimSpace(fields[0])) + if len(h) != 64 { + return "", fmt.Errorf("invalid sha256 in checksum file (len=%d)", len(h)) + } + return h, nil +} + +func majorVersionFromFullVersion(ver string) string { + ver = strings.TrimSpace(ver) + parts := strings.Split(ver, ".") + if len(parts) >= 2 { + return parts[0] + "." + parts[1] + } + return "" +} + +func isTwoSegmentMajor(s string) bool { + parts := strings.Split(strings.TrimSpace(s), ".") + return len(parts) == 2 && parts[0] != "" && parts[1] != "" +} + +func extensionFromDownloadURL(downloadURL string) string { + parsed, err := neturl.Parse(downloadURL) + if err != nil { + return ".bin" + } + ext := path.Ext(parsed.Path) + if strings.TrimSpace(ext) == "" { + return ".bin" + } + return ext +} + +// SetExpectedSHA256 stores expected hash by platform classifier. +func (a *Adapter) SetExpectedSHA256(classifier, hash string) { + a.mu.Lock() + defer a.mu.Unlock() + a.expectedSHA256ByClass[classifier] = strings.ToLower(strings.TrimSpace(hash)) +} + +// ExpectedSHA256 returns expected hash by classifier. +func (a *Adapter) ExpectedSHA256(classifier string) string { + a.mu.RLock() + defer a.mu.RUnlock() + return a.expectedSHA256ByClass[classifier] +} + +type sha256Verifier struct { + adapter *Adapter +} + +func (v *sha256Verifier) Verify(ctx context.Context, result runtime.DownloadResult) error { + _ = ctx + expected := v.adapter.ExpectedSHA256(result.Platform.Classifier) + if expected == "" { + if err := runtime.WriteChecksumAuditRecord(result, "", "", false, "failed", "missing expected sha256 for platform"); err != nil { + return fmt.Errorf("missing expected sha256 for platform %s and failed writing proof files: %w", result.Platform.Classifier, err) + } + return fmt.Errorf("missing expected sha256 for platform %s", result.Platform.Classifier) + } + + file, err := os.Open(result.LocalPath) + if err != nil { + return fmt.Errorf("open downloaded file: %w", err) + } + defer func() { _ = file.Close() }() + + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + if proofErr := runtime.WriteChecksumAuditRecord(result, expected, "", false, "failed", "failed to compute sha256"); proofErr != nil { + return fmt.Errorf("compute sha256 failed: %v; failed writing proof files: %w", err, proofErr) + } + return fmt.Errorf("compute sha256: %w", err) + } + actual := hex.EncodeToString(hash.Sum(nil)) + if actual != expected { + if err := runtime.WriteChecksumAuditRecord(result, expected, actual, false, "failed", "sha256 mismatch"); err != nil { + return fmt.Errorf("sha256 mismatch for %s and failed writing proof files: %w", result.Platform.Classifier, err) + } + return fmt.Errorf("sha256 mismatch for %s: expected %s got %s", result.Platform.Classifier, expected, actual) + } + + if err := runtime.WriteChecksumAuditRecord(result, expected, actual, true, "success", ""); err != nil { + return fmt.Errorf("failed writing proof files: %w", err) + } + return nil +} + +func (v *sha256Verifier) GetType() string { + return "checksum-sha256" +} + +func (v *sha256Verifier) RequiresAdditionalFiles() bool { + return false +} diff --git a/internal/runtimes/jetbrains/adapter_test.go b/internal/runtimes/jetbrains/adapter_test.go new file mode 100644 index 0000000..21e7b95 --- /dev/null +++ b/internal/runtimes/jetbrains/adapter_test.go @@ -0,0 +1,25 @@ +package jetbrains + +import ( + "strings" + "testing" +) + +func TestParseChecksumLine(t *testing.T) { + line := strings.Repeat("ab", 32) + " *file.tar.gz" + got, err := ParseChecksumLine(line) + if err != nil { + t.Fatalf("ParseChecksumLine: %v", err) + } + want := strings.Repeat("ab", 32) + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestParseChecksumLine_InvalidLength(t *testing.T) { + _, err := ParseChecksumLine("abcd *file.tar.gz") + if err == nil { + t.Fatal("expected error for invalid checksum length") + } +} diff --git a/internal/runtimes/pycharm/pycharm.go b/internal/runtimes/pycharm/pycharm.go new file mode 100644 index 0000000..9fe1a2d --- /dev/null +++ b/internal/runtimes/pycharm/pycharm.go @@ -0,0 +1,35 @@ +// Package pycharm provides a PyCharm Professional runtime adapter using shared JetBrains runtime logic. +package pycharm + +import ( + "log/slog" + + "github.com/clean-dependency-project/cdprun/internal/config" + "github.com/clean-dependency-project/cdprun/internal/runtime" + "github.com/clean-dependency-project/cdprun/internal/runtimes/jetbrains" +) + +const ( + // PyCharmProfessionalRuntime is the registry key for PyCharm Professional (PCP). + PyCharmProfessionalRuntime = "pycharm_professional" + defaultProductCode = "PCP" +) + +// Adapter is a thin PyCharm wrapper around the shared JetBrains adapter. +type Adapter struct { + *jetbrains.Adapter +} + +// NewAdapterWithConfig builds a PyCharm Professional adapter. +func NewAdapterWithConfig(cfg *config.Runtime, globalCfg *config.GlobalConfig, stdout, stderr *slog.Logger) runtime.RuntimeProvider { + return &Adapter{ + Adapter: jetbrains.NewAdapterWithConfig(cfg, globalCfg, jetbrains.ProductOptions{ + RuntimeName: PyCharmProfessionalRuntime, + DefaultProductCode: defaultProductCode, + DefaultReleaseType: "release", + DefaultReleasesURL: "https://data.services.jetbrains.com/products/releases", + DefaultUserAgent: "cdprun/1.0 (PyCharm)", + ArtifactPrefix: "pycharm-professional", + }, stdout, stderr), + } +} diff --git a/internal/runtimes/pycharm/pycharm_test.go b/internal/runtimes/pycharm/pycharm_test.go new file mode 100644 index 0000000..3cce58d --- /dev/null +++ b/internal/runtimes/pycharm/pycharm_test.go @@ -0,0 +1,102 @@ +package pycharm + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/clean-dependency-project/cdprun/internal/config" + "github.com/clean-dependency-project/cdprun/internal/endoflife" + "github.com/clean-dependency-project/cdprun/internal/platform" + "github.com/clean-dependency-project/cdprun/internal/runtime" +) + +func newJetBrainsTestServer(t *testing.T) *httptest.Server { + t.Helper() + var ts *httptest.Server + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/products/releases": + if r.URL.Query().Get("code") != "PCP" { + http.Error(w, "bad code", http.StatusBadRequest) + return + } + body := fmt.Sprintf( + `{"PCP":[{"version":"2099.1.1","downloads":{"windows":{"link":"%s/file.exe","checksumLink":"%s/file.exe.sha256"}}}]}`, + ts.URL, ts.URL, + ) + _, _ = w.Write([]byte(body)) + case "/file.exe.sha256": + _, _ = io.WriteString(w, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa *file.exe\n") + default: + http.NotFound(w, r) + } + })) + t.Cleanup(ts.Close) + return ts +} + +func TestAdapter_CreateDownloadTasks_StoresExpectedSHA(t *testing.T) { + ts := newJetBrainsTestServer(t) + + cfg := &config.Runtime{ + Download: config.DownloadConfig{ + BaseURL: ts.URL + "/products/releases", + JetBrainsCode: "PCP", + UserAgent: "test", + }, + } + adapter := NewAdapterWithConfig(cfg, &config.GlobalConfig{}, slog.Default(), slog.Default()).(*Adapter) + versionInfo := endoflife.VersionInfo{Version: "2099.1.1", LatestPatch: "2099.1.1"} + platforms := []platform.Platform{ + {OS: "windows", Arch: "x64", DownloadName: "windows", Classifier: "windows-x64"}, + } + + tasks, err := adapter.CreateDownloadTasks(versionInfo, platforms, t.TempDir()) + if err != nil { + t.Fatalf("CreateDownloadTasks() error = %v", err) + } + if len(tasks) != 1 { + t.Fatalf("len tasks = %d, want 1", len(tasks)) + } + if !strings.HasSuffix(tasks[0].URL, "/file.exe") { + t.Fatalf("unexpected URL: %s", tasks[0].URL) + } + if got := adapter.ExpectedSHA256("windows-x64"); got == "" { + t.Fatal("expected sha stored") + } +} + +func TestAdapter_GetLatestVersion(t *testing.T) { + ts := newJetBrainsTestServer(t) + + adapter := NewAdapterWithConfig(&config.Runtime{ + Download: config.DownloadConfig{ + BaseURL: ts.URL + "/products/releases", + JetBrainsCode: "PCP", + }, + }, &config.GlobalConfig{}, slog.Default(), slog.Default()).(*Adapter) + + latest, err := adapter.GetLatestVersion(context.Background(), runtime.VersionOptions{}) + if err != nil { + t.Fatalf("GetLatestVersion() error = %v", err) + } + if latest.LatestPatch != "2099.1.1" { + t.Fatalf("LatestPatch = %s, want 2099.1.1", latest.LatestPatch) + } + if latest.RuntimeName != PyCharmProfessionalRuntime { + t.Fatalf("RuntimeName = %s, want %s", latest.RuntimeName, PyCharmProfessionalRuntime) + } +} + +func TestAdapter_GetEndOfLifeProduct_Empty(t *testing.T) { + a := NewAdapterWithConfig(nil, nil, slog.Default(), slog.Default()).(*Adapter) + if got := a.GetEndOfLifeProduct(); got != "" { + t.Fatalf("GetEndOfLifeProduct() = %q, want empty (not on endoflife.date)", got) + } +} diff --git a/runtime-registry.yaml b/runtime-registry.yaml index 7d3862a..5c766c3 100644 --- a/runtime-registry.yaml +++ b/runtime-registry.yaml @@ -463,6 +463,60 @@ runtimes: github_repository: "Clean-Dependency-Project/cdprun" draft_release: false release_name_template: "IntelliJ IDEA Ultimate - {version}" + packaging: + enabled: false + targets: [] + + pycharm_professional: + enabled: true + name: "PyCharm Professional" + description: "PyCharm Professional (PCP) — versions from JetBrains releases API only; not on endoflife.date" + endoflife_product: "" + policy_file: "" + version_pattern: "major" + supported_architectures: + - "x64" + - "aarch64" + supported_platforms: + - os: "mac" + arch: ["aarch64"] + file_extension: "dmg" + download_name: "macM1" + - os: "windows" + arch: ["aarch64"] + file_extension: "exe" + download_name: "windowsARM64" + - os: "windows" + arch: ["x64"] + file_extension: "exe" + download_name: "windows" + download: + base_url: "https://data.services.jetbrains.com/products/releases" + jetbrains_code: "PCP" + jetbrains_type: "release" + url_pattern: "" + user_agent: "cdprun/1.0 (PyCharm)" + requires_auth: false + verification: + enabled: true + methods: + checksum: + enabled: true + algorithm: "sha256" + file_pattern: "jetbrains#checksumLink" + remote_checksum: true + gpg: + enabled: false + signature_pattern: "" + endoflife: + product_name: "" + check_eol: false + warn_on_eol: false + release: + auto_release: true + github_repository: "Clean-Dependency-Project/cdprun" + draft_release: false + release_name_template: "PyCharm Professional - {version}" packaging: enabled: false targets: [] \ No newline at end of file