• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mongodb / mongodb-atlas-cli / 16670310313

01 Aug 2025 08:29AM UTC coverage: 57.953% (-7.1%) from 65.017%
16670310313

Pull #4071

github

fmenezes
lint
Pull Request #4071: chore: remove unit tag from tests

23613 of 40745 relevant lines covered (57.95%)

2.74 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

75.6
/internal/cli/plugin/plugin_github_asset.go
1
// Copyright 2024 MongoDB Inc
2
//
3
// Licensed under the Apache License, Version 2.0 (the "License");
4
// you may not use this file except in compliance with the License.
5
// You may obtain a copy of the License at
6
//
7
//      http://www.apache.org/licenses/LICENSE-2.0
8
//
9
// Unless required by applicable law or agreed to in writing, software
10
// distributed under the License is distributed on an "AS IS" BASIS,
11
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
// See the License for the specific language governing permissions and
13
// limitations under the License.
14

15
package plugin
16

17
import (
18
        "bytes"
19
        "context"
20
        "errors"
21
        "fmt"
22
        "io"
23
        "io/fs"
24
        "net/http"
25
        "os"
26
        "path/filepath"
27
        "regexp"
28
        "runtime"
29
        "slices"
30
        "strings"
31

32
        "github.com/Masterminds/semver/v3"
33
        "github.com/ProtonMail/go-crypto/openpgp"
34
        "github.com/google/go-github/v61/github"
35
        "github.com/mholt/archives"
36
        "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/log"
37
        "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/plugin"
38
)
39

40
var (
41
        errGithubParametersInvalid      = errors.New(`github parameter is invalid. It needs to have the format "<github-owner>/<github-repository-name>"`)
42
        errCreatePluginArchiveFile      = errors.New("could not create plugin archive file")
43
        errSaveAssetToPluginDir         = errors.New("failed to save asset to plugin directory")
44
        errCreateDirToExtractAssetFiles = errors.New("failed to create to plugin directory to extract assets in")
45
        errCreatePluginAssetFromPlugin  = errors.New("failed to create plugin asset from plugin")
46
)
47

48
const (
49
        latest    = "latest"
50
        publicKey = "signature.asc"
51
)
52

53
type GithubAsset struct {
54
        owner   string
55
        name    string
56
        version *semver.Version
57
}
58

59
func (g *GithubAsset) repository() string {
1✔
60
        return fmt.Sprintf("%s/%s", g.owner, g.name)
1✔
61
}
1✔
62

63
func createGithubAssetFromPlugin(p *plugin.Plugin, version *semver.Version) (*GithubAsset, error) {
1✔
64
        if !p.HasGithub() {
1✔
65
                return nil, errCreatePluginAssetFromPlugin
×
66
        }
×
67

68
        return &GithubAsset{
1✔
69
                owner:   p.Github.Owner,
1✔
70
                name:    p.Github.Name,
1✔
71
                version: version,
1✔
72
        }, nil
1✔
73
}
74

75
func (g *GithubAsset) getPluginDirectoryName() string {
1✔
76
        return fmt.Sprintf("%s@%s", g.owner, g.name)
1✔
77
}
1✔
78

79
func (g *GithubAsset) getReleaseAssets(ghClient *github.Client) ([]*github.ReleaseAsset, error) {
1✔
80
        var err error
1✔
81
        var release *github.RepositoryRelease
1✔
82

1✔
83
        // download latest release if version is not specified
1✔
84
        if g.version == nil {
2✔
85
                // download the 100 latest releases
1✔
86
                const MaxPerPage = 100
1✔
87
                releases, _, err := ghClient.Repositories.ListReleases(context.Background(), g.owner, g.name, &github.ListOptions{
1✔
88
                        Page:    0,
1✔
89
                        PerPage: MaxPerPage,
1✔
90
                })
1✔
91

1✔
92
                if err != nil {
2✔
93
                        return nil, fmt.Errorf("could not fetch releases for %s, access to GitHub is required, see https://dochub.mongodb.org/core/atlas-cli-deploy-docker\n"+
1✔
94
                                "if you are using a private repository, you need to set the GH_TOKEN environment variable (needs content read access to the repository) or authenticate with the github cli\n"+
1✔
95
                                "more info about access tokens: https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token\n\n"+
1✔
96
                                "error: %w", g.repository(), err)
1✔
97
                }
1✔
98

99
                // get the latest release that doesn't have prerelease info or metadata in the version tag
100
                release = getLatestStableRelease(releases)
1✔
101
                if release == nil {
1✔
102
                        return nil, fmt.Errorf("could not find latest stable release for %s", g.repository())
×
103
                }
×
104
        } else {
1✔
105
                // try to find the release with the version tag with v prefix, if it does not exist try again without the prefix
1✔
106
                release, _, err = ghClient.Repositories.GetReleaseByTag(context.Background(), g.owner, g.name, "v"+g.version.String())
1✔
107

1✔
108
                if release == nil || err != nil {
2✔
109
                        release, _, err = ghClient.Repositories.GetReleaseByTag(context.Background(), g.owner, g.name, g.version.String())
1✔
110
                }
1✔
111

112
                if err != nil {
2✔
113
                        return nil, fmt.Errorf("could not find the release %s for %s", g.version, g.repository())
1✔
114
                }
1✔
115
        }
116

117
        return release.Assets, nil
1✔
118
}
119

120
func getLatestStableRelease(releases []*github.RepositoryRelease) *github.RepositoryRelease {
1✔
121
        var latestStableVersion *semver.Version
1✔
122
        var latestStableRelease *github.RepositoryRelease
1✔
123

1✔
124
        for _, release := range releases {
2✔
125
                version, err := semver.NewVersion(*release.TagName)
1✔
126

1✔
127
                // if we can't parse the version tag, skip this release
1✔
128
                if err != nil {
1✔
129
                        continue
×
130
                }
131

132
                // if the version has pre-release info or metadata, skip this version
133
                if version.Prerelease() != "" || version.Metadata() != "" {
1✔
134
                        continue
×
135
                }
136

137
                if latestStableVersion == nil || version.GreaterThan(latestStableVersion) {
2✔
138
                        latestStableVersion = version
1✔
139
                        latestStableRelease = release
1✔
140
                }
1✔
141
        }
142

143
        return latestStableRelease
1✔
144
}
145

146
var architectureAliases = map[string][]string{
147
        "amd64": {"x86_64"},
148
        "arm64": {"aarch64"},
149
        "386":   {"i386", "x86"},
150
}
151

152
//nolint:mnd
153
var contentTypePriority = map[string]int{
154
        "application/gzip":   0, // tar.gz
155
        "application/x-gtar": 1, // tar.gz
156
        "application/x-gzip": 2, // tar.gz
157
        "application/zip":    3, // zip
158
}
159

160
func (g *GithubAsset) getIDs(assets []*github.ReleaseAsset) (int64, int64, int64, error) {
1✔
161
        return g.getIDsForOSArch(assets, runtime.GOOS, runtime.GOARCH)
1✔
162
}
1✔
163

164
func (g *GithubAsset) getIDsForOSArch(assets []*github.ReleaseAsset, goos, goarch string) (int64, int64, int64, error) {
1✔
165
        // Get all possible architecture names for the current architecture
1✔
166
        archNames := []string{goarch}
1✔
167
        if aliases, ok := architectureAliases[goarch]; ok {
2✔
168
                archNames = append(archNames, aliases...)
1✔
169
        }
1✔
170

171
        var archiveAssets []*github.ReleaseAsset
1✔
172
        for _, asset := range assets {
2✔
173
                if asset.ContentType == nil || asset.Name == nil {
1✔
174
                        continue
×
175
                }
176

177
                if _, ok := contentTypePriority[*asset.ContentType]; !ok {
2✔
178
                        continue
1✔
179
                }
180

181
                name := strings.ToLower(*asset.Name)
1✔
182
                if !strings.Contains(name, goos) {
2✔
183
                        continue
1✔
184
                }
185

186
                // Check if any of the architecture names match
187
                for _, arch := range archNames {
2✔
188
                        if strings.Contains(name, arch) {
2✔
189
                                archiveAssets = append(archiveAssets, asset)
1✔
190
                                break
1✔
191
                        }
192
                }
193
        }
194

195
        if len(archiveAssets) == 0 {
1✔
196
                return 0, 0, 0, fmt.Errorf("no compatible asset found in %s for OS=%s, arch=%s (including aliases: %v)",
×
197
                        g.repository(), goos, goarch, archNames[1:])
×
198
        }
×
199

200
        // Sort by content type priority
201
        slices.SortFunc(archiveAssets, func(a, b *github.ReleaseAsset) int {
1✔
202
                return contentTypePriority[*a.ContentType] - contentTypePriority[*b.ContentType]
×
203
        })
×
204
        name := strings.ToLower(*archiveAssets[0].Name)
1✔
205
        signatureID, pubKeyID, err := getSignatureAssetandKeyID(name, assets)
1✔
206
        if err != nil {
1✔
207
                return 0, 0, 0, err
×
208
        }
×
209
        return *archiveAssets[0].ID, signatureID, pubKeyID, nil
1✔
210
}
211

212
func getSignatureAssetandKeyID(name string, assets []*github.ReleaseAsset) (int64, int64, error) {
1✔
213
        var signatureAsset *github.ReleaseAsset
1✔
214
        var pubKeyAsset *github.ReleaseAsset
1✔
215

1✔
216
        for _, asset := range assets {
2✔
217
                assetName := strings.ToLower(*asset.Name)
1✔
218

1✔
219
                // Check if asset is a public key
1✔
220
                if strings.Compare(assetName, publicKey) == 0 {
2✔
221
                        pubKeyAsset = asset
1✔
222
                        continue
1✔
223
                }
224

225
                // Check if asset is a signature
226
                if strings.Contains(assetName, name+".sig") {
2✔
227
                        signatureAsset = asset
1✔
228
                }
1✔
229
        }
230

231
        // If no signature package is found, provide warning
232
        if signatureAsset == nil {
2✔
233
                _, _ = log.Warningf("-- plugin warning: no corresponding signature asset found for package %s\n", name)
1✔
234
                return 0, 0, nil
1✔
235
        }
1✔
236

237
        // If signature package exists but public key does not, return error
238
        if pubKeyAsset == nil {
1✔
239
                return 0, 0, fmt.Errorf("-- plugin warning: no public key '%s' found for signature verification", publicKey)
×
240
        }
×
241

242
        return *signatureAsset.ID, *pubKeyAsset.ID, nil
1✔
243
}
244

245
func (g *GithubAsset) getPluginAssetsAsReadCloser(ghClient *github.Client, assetID, sigAssetID, pubKeyAssetID int64) (io.ReadCloser, error) {
1✔
246
        rc, _, err := ghClient.Repositories.DownloadReleaseAsset(context.Background(), g.owner, g.name, assetID, http.DefaultClient)
1✔
247
        if err != nil {
1✔
248
                return nil, fmt.Errorf("could not download asset with ID %d from %s", assetID, g.repository())
×
249
        }
×
250

251
        asset, err := io.ReadAll(rc)
1✔
252
        if err != nil {
1✔
253
                return nil, errors.New("could not convert reader to bytes")
×
254
        }
×
255

256
        // Only do verification if IDs are not 0, i.e. when there is signature package available
257
        if sigAssetID != 0 && pubKeyAssetID != 0 {
2✔
258
                err = g.verifyAssetSignature(ghClient, asset, sigAssetID, pubKeyAssetID)
1✔
259
                if err != nil {
1✔
260
                        return nil, err
×
261
                }
×
262
        }
263

264
        return io.NopCloser(bytes.NewReader(asset)), nil
1✔
265
}
266

267
// verifyAssetSignature verifies the asset signature.
268
// Returns nil if signature check is successful.
269
func (g *GithubAsset) verifyAssetSignature(ghClient *github.Client, asset []byte, sigAssetID, pubKeyAssetID int64) error {
1✔
270
        sigRc, _, err := ghClient.Repositories.DownloadReleaseAsset(context.Background(), g.owner, g.name, sigAssetID, http.DefaultClient)
1✔
271
        if err != nil {
1✔
272
                return fmt.Errorf("could not download signature asset with ID %d from %s", sigAssetID, g.repository())
×
273
        }
×
274
        defer sigRc.Close()
1✔
275

1✔
276
        keyRc, _, err := ghClient.Repositories.DownloadReleaseAsset(context.Background(), g.owner, g.name, pubKeyAssetID, http.DefaultClient)
1✔
277
        if err != nil {
1✔
278
                return fmt.Errorf("could not download public key asset with ID %d from %s", pubKeyAssetID, g.repository())
×
279
        }
×
280
        defer keyRc.Close()
1✔
281

1✔
282
        key, err := openpgp.ReadArmoredKeyRing(keyRc)
1✔
283
        if err != nil {
1✔
284
                return err
×
285
        }
×
286

287
        _, err = openpgp.CheckArmoredDetachedSignature(key, bytes.NewReader(asset), sigRc, nil)
1✔
288
        if err != nil {
1✔
289
                return fmt.Errorf("signature verification unsuccessful: %w", err)
×
290
        }
×
291

292
        fmt.Println("PGP signature verification successful!")
1✔
293
        return nil
1✔
294
}
295

296
var ghReleaseReg = regexp.MustCompile(`^((https?://(www\.)?)?github\.com/)?(?P<owner>[\w.\-]+)/(?P<name>[\w.\-]+)/?(@(?P<version>.+))?$`)
297

298
func parseGithubReleaseValues(arg string) (*GithubAsset, error) {
1✔
299
        matches := ghReleaseReg.FindStringSubmatch(arg)
1✔
300
        if matches == nil {
2✔
301
                return nil, errGithubParametersInvalid
1✔
302
        }
1✔
303

304
        names := ghReleaseReg.SubexpNames()
1✔
305
        groupMap := make(map[string]string)
1✔
306
        for i, match := range matches {
2✔
307
                if i == 0 {
2✔
308
                        continue
1✔
309
                }
310
                groupMap[names[i]] = match
1✔
311
        }
312

313
        githubRelease := &GithubAsset{owner: groupMap["owner"], name: groupMap["name"]}
1✔
314

1✔
315
        if version, ok := groupMap["version"]; ok && version != latest && version != "" {
2✔
316
                version := strings.TrimPrefix(version, "v")
1✔
317
                semverVersion, err := semver.NewVersion(version)
1✔
318
                if err != nil {
2✔
319
                        return nil, fmt.Errorf(`the specified version "%s" is invalid, it needs to follow the rules of Semantic Versioning`, version)
1✔
320
                }
1✔
321
                githubRelease.version = semverVersion
1✔
322
        }
323

324
        return githubRelease, nil
1✔
325
}
326

327
func saveReadCloserToPluginAssetArchiveFile(rc io.ReadCloser) (string, error) {
1✔
328
        defer rc.Close()
1✔
329

1✔
330
        pluginsDefaultDirectory, err := plugin.GetDefaultPluginDirectory()
1✔
331
        if err != nil {
1✔
332
                return "", err
×
333
        }
×
334

335
        pluginArchiveFilePath := filepath.Join(pluginsDefaultDirectory, "plugin.partial")
1✔
336
        pluginTarGzFile, err := os.Create(pluginArchiveFilePath)
1✔
337
        if err != nil {
1✔
338
                return "", errCreatePluginArchiveFile
×
339
        }
×
340
        defer pluginTarGzFile.Close()
1✔
341

1✔
342
        _, err = io.Copy(pluginTarGzFile, rc)
1✔
343
        if err != nil {
1✔
344
                os.Remove(pluginArchiveFilePath)
×
345
                return "", errSaveAssetToPluginDir
×
346
        }
×
347

348
        return pluginArchiveFilePath, nil
1✔
349
}
350

351
func extractPluginAssetArchiveFile(ctx context.Context, pluginArchivePath string, pluginDirectoryName string) (string, error) {
1✔
352
        pluginsDefaultDirectory, err := plugin.GetDefaultPluginDirectory()
1✔
353
        if err != nil {
1✔
354
                return "", err
×
355
        }
×
356

357
        pluginDirectoryPath := filepath.Join(pluginsDefaultDirectory, pluginDirectoryName)
1✔
358
        err = os.MkdirAll(pluginDirectoryPath, os.ModePerm)
1✔
359
        if err != nil {
1✔
360
                return "", errCreateDirToExtractAssetFiles
×
361
        }
×
362

363
        if err = extractArchive(ctx, pluginArchivePath, pluginDirectoryPath); err != nil {
1✔
364
                os.RemoveAll(pluginDirectoryPath)
×
365
                return pluginDirectoryPath, err
×
366
        }
×
367

368
        return pluginDirectoryPath, nil
1✔
369
}
370

371
func extractArchive(ctx context.Context, pluginArchivePath string, pluginDirectoryName string) error {
1✔
372
        // Strip prefix
1✔
373
        prefix, err := getArchivePrefix(ctx, pluginArchivePath)
1✔
374
        if err != nil {
1✔
375
                return fmt.Errorf("failed to determine archive prefix: %w", err)
×
376
        }
×
377

378
        archiveFile, err := os.Open(pluginArchivePath)
1✔
379
        if err != nil {
1✔
380
                return fmt.Errorf("failed to open source file: %w", err)
×
381
        }
×
382
        defer archiveFile.Close()
1✔
383

1✔
384
        // Identify the archive format
1✔
385
        // The library we're using supports: zip, .tar, .tar.gz, .rar, .7z
1✔
386
        format, _, err := archives.Identify(ctx, pluginArchivePath, archiveFile)
1✔
387
        if err != nil {
1✔
388
                return fmt.Errorf("failed to identify archive format: %w", err)
×
389
        }
×
390

391
        // Try to get an extractor for the format
392
        ex, ok := format.(archives.Extractor)
1✔
393
        if !ok {
1✔
394
                return fmt.Errorf("%s is not supported", format.MediaType())
×
395
        }
×
396

397
        // Extract the archive
398
        if err := ex.Extract(ctx, archiveFile, func(_ context.Context, fileInfo archives.FileInfo) error {
2✔
399
                // Get the destination path
1✔
400
                destPath := filepath.Join(pluginDirectoryName, strings.TrimPrefix(fileInfo.NameInArchive, prefix))
1✔
401

1✔
402
                // Handle directories
1✔
403
                if fileInfo.IsDir() {
1✔
404
                        return os.MkdirAll(destPath, fileInfo.Mode())
×
405
                }
×
406

407
                // Only handle regular files
408
                if !fileInfo.Mode().IsRegular() {
1✔
409
                        return fmt.Errorf("plugin archive should only contain directoreis and regular files, encountered: %s", fileInfo.Mode())
×
410
                }
×
411

412
                // Create parent directories if they don't exist
413
                if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil {
1✔
414
                        return fmt.Errorf("failed to create parent directory: %w", err)
×
415
                }
×
416

417
                // Open file in archive
418
                file, err := fileInfo.Open()
1✔
419
                if err != nil {
1✔
420
                        return fmt.Errorf("failed to open file: %w", err)
×
421
                }
×
422
                defer file.Close()
1✔
423

1✔
424
                // Create the file
1✔
425
                destFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileInfo.Mode())
1✔
426
                if err != nil {
1✔
427
                        return fmt.Errorf("failed to create destination file: %w", err)
×
428
                }
×
429
                defer destFile.Close()
1✔
430

1✔
431
                // Copy file contents
1✔
432
                if _, err := io.Copy(destFile, file); err != nil {
1✔
433
                        return fmt.Errorf("failed to copy contents to destination file: %w", err)
×
434
                }
×
435

436
                return nil
1✔
437
        }); err != nil {
×
438
                return fmt.Errorf("failed to extract archive: %w", err)
×
439
        }
×
440

441
        return nil
1✔
442
}
443

444
func getArchivePrefix(ctx context.Context, pluginArchivePath string) (string, error) {
1✔
445
        fsys, err := archives.FileSystem(ctx, pluginArchivePath, nil)
1✔
446
        if err != nil {
1✔
447
                return "", fmt.Errorf("failed to open archive file: %w", err)
×
448
        }
×
449

450
        // Read the contents of the archive root
451
        entries, err := fs.ReadDir(fsys, ".")
1✔
452
        if err != nil {
1✔
453
                return "", fmt.Errorf("failed to read root directory of archive: %w", err)
×
454
        }
×
455

456
        // Strip prefix
457
        prefix := ""
1✔
458
        if len(entries) == 1 {
2✔
459
                entry := entries[0]
1✔
460
                if entry.IsDir() {
2✔
461
                        prefix = entry.Name()
1✔
462
                }
1✔
463
        }
464

465
        return prefix, nil
1✔
466
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc