1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "bufio"
7 "context"
8 "crypto/sha256"
9 "encoding/hex"
10 "errors"
11 "fmt"
12 "io"
13 "mime"
14 "net/http"
15 "net/url"
16 "os"
17 "path/filepath"
18 "slices"
19 "strconv"
20 "strings"
21
22 "connectrpc.com/connect"
23 "github.com/spf13/cobra"
24 "golang.org/x/term"
25 "google.golang.org/protobuf/types/known/fieldmaskpb"
26
27 "bnema.dev/ermite-api/gen/ermite"
28 "bnema.dev/ermite/internal/adapters/client"
29)
30
31var errNoReleases = errors.New("the server has no releases (it lacks the releases feature)")
32
33func requireReleases(ctx context.Context, c *client.Client) error {
34 res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
35 if err != nil {
36 return c.Describe(err)
37 }
38 if !slices.Contains(res.Msg.GetFeatures(), client.FeatureReleases) {
39 return errNoReleases
40 }
41 return nil
42}
43
44func releaseClient(env *cliEnv, cmd *cobra.Command) (*client.Client, error) {
45 c, err := env.client(cmd)
46 if err != nil {
47 return nil, err
48 }
49 if err := requireReleases(env.ctx(cmd), c); err != nil {
50 return nil, err
51 }
52 return c, nil
53}
54
55// jsonRelease and jsonReleaseAsset keep the CLI shape independent of protobuf JSON.
56type jsonReleaseAsset struct {
57 ID int64 `json:"id"`
58 ReleaseID int64 `json:"release_id"`
59 Name string `json:"name"`
60 Label string `json:"label"`
61 ContentType string `json:"content_type"`
62 Size int64 `json:"size"`
63 Digest string `json:"digest"`
64 Uploader string `json:"uploader"`
65 CreatedAt *string `json:"created_at"`
66 UpdatedAt *string `json:"updated_at"`
67}
68type jsonRelease struct {
69 ID int64 `json:"id"`
70 Repo string `json:"repo"`
71 Tag string `json:"tag"`
72 Title string `json:"title"`
73 Notes string `json:"notes"`
74 Draft bool `json:"draft"`
75 Prerelease bool `json:"prerelease"`
76 Latest bool `json:"latest"`
77 TargetCommitish string `json:"target_commitish"`
78 Author string `json:"author"`
79 CreatedAt *string `json:"created_at"`
80 PublishedAt *string `json:"published_at"`
81 UpdatedAt *string `json:"updated_at"`
82 Assets []jsonReleaseAsset `json:"assets"`
83}
84
85func toJSONRelease(r *ermite.Release) jsonRelease {
86 out := jsonRelease{ID: r.GetId(), Repo: r.GetRepo(), Tag: r.GetTag(), Title: r.GetTitle(), Notes: r.GetNotes(), Draft: r.GetDraft(), Prerelease: r.GetPrerelease(), Latest: r.GetLatest(), TargetCommitish: r.GetTargetCommitish(), Author: r.GetAuthor(), CreatedAt: jsonTime(r.GetCreatedAt()), PublishedAt: jsonTime(r.GetPublishedAt()), UpdatedAt: jsonTime(r.GetUpdatedAt()), Assets: []jsonReleaseAsset{}}
87 for _, a := range r.GetAssets() {
88 out.Assets = append(out.Assets, jsonReleaseAsset{a.GetId(), a.GetReleaseId(), a.GetName(), a.GetLabel(), a.GetContentType(), a.GetSize(), a.GetDigest(), a.GetUploader(), jsonTime(a.GetCreatedAt()), jsonTime(a.GetUpdatedAt())})
89 }
90 return out
91}
92
93const releaseJSONHelp = "\nJSON fields: id, repo, tag, title, notes, draft, prerelease, latest, target_commitish, author,\ncreated_at, published_at, updated_at (RFC 3339 UTC or null), assets (id, release_id, name, label,\ncontent_type, size, digest, uploader, created_at, updated_at). list prints an array."
94
95func newReleaseCmd(env *cliEnv) *cobra.Command {
96 cmd := &cobra.Command{Use: "release", Short: "Manage releases and their assets", Long: "Create and manage tagged releases. Reads follow repository visibility; writes need release:write.\nNeeds a server with the releases feature. Select releases by tag, numeric id, or latest (view/download)."}
97 cmd.AddCommand(newReleaseCreateCmd(env), newReleaseListCmd(env), newReleaseViewCmd(env), newReleaseEditCmd(env), newReleaseDeleteCmd(env), newReleaseUploadCmd(env), newReleaseDownloadCmd(env))
98 return cmd
99}
100func releaseCommand(env *cliEnv, use, short string, n int) *cobra.Command {
101 return &cobra.Command{Use: use, Short: short, Args: cobra.ExactArgs(n), ValidArgsFunction: completeRepoNames(env)}
102}
103
104func getRelease(ctx context.Context, c *client.Client, repo, selector string, latest bool) (*ermite.Release, error) {
105 req := &ermite.GetReleaseRequest{Repo: repo}
106 if selector == "latest" && latest {
107 req.Selector = &ermite.GetReleaseRequest_Latest{Latest: true}
108 } else if selector != "" && strings.Trim(selector, "0123456789") == "" {
109 id, err := strconv.ParseInt(selector, 10, 64)
110 if err != nil || id <= 0 {
111 return nil, fmt.Errorf("invalid release id %q", selector)
112 }
113 req.Selector = &ermite.GetReleaseRequest_Id{Id: id}
114 } else {
115 req.Selector = &ermite.GetReleaseRequest_Tag{Tag: selector}
116 }
117 res, err := c.Releases().GetRelease(ctx, connect.NewRequest(req))
118 if err != nil {
119 return nil, c.Describe(err)
120 }
121 return res.Msg.GetRelease(), nil
122}
123func releaseState(r *ermite.Release) string {
124 if r.GetDraft() {
125 return "draft"
126 }
127 if r.GetPrerelease() {
128 return "prerelease"
129 }
130 return "published"
131}
132func releaseURL(base, repo, tag string) string {
133 return strings.TrimRight(base, "/") + "/" + url.PathEscape(repo) + "/releases/tag/" + url.PathEscape(tag)
134}
135func outputRelease(cmd *cobra.Command, c *client.Client, r *ermite.Release, asJSON bool) error {
136 if asJSON {
137 return writeJSON(cmd.OutOrStdout(), toJSONRelease(r))
138 }
139 _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\nid: %d\n", releaseURL(c.URL(), r.GetRepo(), r.GetTag()), r.GetId())
140 return err
141}
142
143type releaseFields struct {
144 title, notes, notesFile, target, tag string
145 draft, publish, prerelease, noPrerelease, latest bool
146}
147
148func (f *releaseFields) flags(cmd *cobra.Command, edit bool) {
149 cmd.Flags().StringVar(&f.title, "title", "", "release title")
150 cmd.Flags().StringVar(&f.notes, "notes", "", "Markdown release notes")
151 cmd.Flags().StringVar(&f.notesFile, "notes-file", "", "read Markdown release notes from a file (- for stdin)")
152 cmd.MarkFlagsMutuallyExclusive("notes", "notes-file")
153 cmd.Flags().StringVar(&f.target, "target", "", "target commit or branch for a missing tag")
154 cmd.Flags().BoolVar(&f.draft, "draft", false, "keep as a draft")
155 cmd.Flags().BoolVar(&f.prerelease, "prerelease", false, "mark as a prerelease")
156 cmd.Flags().BoolVar(&f.latest, "latest", false, "pin as latest")
157 if edit {
158 cmd.Flags().StringVar(&f.tag, "tag", "", "new tag")
159 cmd.Flags().BoolVar(&f.publish, "publish", false, "publish a draft")
160 cmd.Flags().BoolVar(&f.noPrerelease, "no-prerelease", false, "clear the prerelease flag")
161 cmd.MarkFlagsMutuallyExclusive("draft", "publish")
162 cmd.MarkFlagsMutuallyExclusive("prerelease", "no-prerelease")
163 }
164}
165func (f *releaseFields) readNotes(cmd *cobra.Command) error {
166 if !cmd.Flags().Changed("notes-file") {
167 return nil
168 }
169 var data []byte
170 var err error
171 if f.notesFile == "-" {
172 data, err = io.ReadAll(cmd.InOrStdin())
173 } else {
174 data, err = os.ReadFile(f.notesFile)
175 }
176 f.notes = string(data)
177 return err
178}
179func newReleaseCreateCmd(env *cliEnv) *cobra.Command {
180 var f releaseFields
181 var assets []string
182 cmd := releaseCommand(env, "create <repo> <tag>", "Create a release", 2)
183 cmd.Long = "Create a release, then upload each --asset FILE[#LABEL] (repeatable; shell expands globs)." + releaseJSONHelp
184 f.flags(cmd, false)
185 cmd.Flags().StringArrayVar(&assets, "asset", nil, "upload FILE[#LABEL] (repeatable)")
186 asJSON := addJSONFlag(cmd)
187 cmd.RunE = func(cmd *cobra.Command, args []string) error {
188 if err := f.readNotes(cmd); err != nil {
189 return err
190 }
191 c, err := releaseClient(env, cmd)
192 if err != nil {
193 return err
194 }
195 ctx := env.ctx(cmd)
196 res, err := c.Releases().CreateRelease(ctx, connect.NewRequest(&ermite.CreateReleaseRequest{Repo: args[0], Tag: args[1], Title: f.title, Notes: f.notes, TargetCommitish: f.target, Draft: f.draft, Prerelease: f.prerelease, MakeLatest: f.latest}))
197 if err != nil {
198 return c.Describe(err)
199 }
200 r := res.Msg.GetRelease()
201 for _, spec := range assets {
202 a, err := uploadReleaseFile(ctx, c, args[0], r.GetId(), spec, "", false)
203 if err != nil {
204 return fmt.Errorf("release %d was created, but asset upload failed: %w", r.GetId(), err)
205 }
206 r.Assets = append(r.Assets, a)
207 }
208 return outputRelease(cmd, c, r, *asJSON)
209 }
210 return cmd
211}
212func newReleaseListCmd(env *cliEnv) *cobra.Command {
213 var limit int32
214 cmd := releaseCommand(env, "list <repo>", "List releases, newest first", 1)
215 cmd.Long = "List releases, including drafts visible to writers." + releaseJSONHelp
216 cmd.Flags().Int32Var(&limit, "limit", 30, "maximum number of releases")
217 asJSON := addJSONFlag(cmd)
218 cmd.RunE = func(cmd *cobra.Command, args []string) error {
219 if limit <= 0 {
220 return errors.New("--limit must be positive")
221 }
222 c, err := releaseClient(env, cmd)
223 if err != nil {
224 return err
225 }
226 all := []*ermite.Release{}
227 page := ""
228 for int32(len(all)) < limit {
229 res, err := c.Releases().ListReleases(env.ctx(cmd), connect.NewRequest(&ermite.ListReleasesRequest{Repo: args[0], PageSize: limit - int32(len(all)), PageToken: page}))
230 if err != nil {
231 return c.Describe(err)
232 }
233 all = append(all, res.Msg.GetReleases()...)
234 next := res.Msg.GetNextPageToken()
235 if next == "" {
236 break
237 }
238 if next == page {
239 return errors.New("the server repeated a release page token")
240 }
241 page = next
242 }
243 if int32(len(all)) > limit {
244 all = all[:limit]
245 }
246 if *asJSON {
247 out := make([]jsonRelease, 0, len(all))
248 for _, r := range all {
249 out = append(out, toJSONRelease(r))
250 }
251 return writeJSON(cmd.OutOrStdout(), out)
252 }
253 rows := [][]string{}
254 for _, r := range all {
255 rows = append(rows, []string{strconv.FormatInt(r.GetId(), 10), r.GetTag(), r.GetTitle(), releaseState(r), shortTime(r.GetPublishedAt(), "-"), strconv.Itoa(len(r.GetAssets()))})
256 }
257 return table(cmd.OutOrStdout(), []string{"ID", "TAG", "TITLE", "STATE", "PUBLISHED", "ASSETS"}, rows)
258 }
259 return cmd
260}
261func newReleaseViewCmd(env *cliEnv) *cobra.Command {
262 var notes bool
263 cmd := releaseCommand(env, "view <repo> <tag|id|latest>", "View a release and its assets", 2)
264 cmd.Long = "View release fields, Markdown notes and assets. --notes prints only Markdown." + releaseJSONHelp
265 asJSON := addJSONFlag(cmd)
266 cmd.Flags().BoolVar(¬es, "notes", false, "print only Markdown notes")
267 cmd.MarkFlagsMutuallyExclusive("json", "notes")
268 cmd.RunE = func(cmd *cobra.Command, args []string) error {
269 c, err := releaseClient(env, cmd)
270 if err != nil {
271 return err
272 }
273 r, err := getRelease(env.ctx(cmd), c, args[0], args[1], true)
274 if err != nil {
275 return err
276 }
277 if *asJSON {
278 return writeJSON(cmd.OutOrStdout(), toJSONRelease(r))
279 }
280 if notes {
281 _, err := fmt.Fprint(cmd.OutOrStdout(), r.GetNotes())
282 return err
283 }
284 if err := fields(cmd.OutOrStdout(), [][2]string{{"id", strconv.FormatInt(r.GetId(), 10)}, {"tag", r.GetTag()}, {"title", r.GetTitle()}, {"state", releaseState(r)}, {"latest", strconv.FormatBool(r.GetLatest())}, {"target", r.GetTargetCommitish()}, {"author", r.GetAuthor()}, {"created", shortTime(r.GetCreatedAt(), "-")}, {"published", shortTime(r.GetPublishedAt(), "-")}, {"updated", shortTime(r.GetUpdatedAt(), "-")}}); err != nil {
285 return err
286 }
287 if _, err := fmt.Fprintln(cmd.OutOrStdout(), clean(r.GetNotes())); err != nil {
288 return err
289 }
290 rows := [][]string{}
291 for _, a := range r.GetAssets() {
292 rows = append(rows, []string{a.GetName(), humanBytes(a.GetSize()), a.GetContentType()})
293 }
294 return table(cmd.OutOrStdout(), []string{"NAME", "SIZE", "TYPE"}, rows)
295 }
296 return cmd
297}
298func newReleaseEditCmd(env *cliEnv) *cobra.Command {
299 var f releaseFields
300 cmd := releaseCommand(env, "edit <repo> <tag|id>", "Edit a release", 2)
301 f.flags(cmd, true)
302 cmd.RunE = func(cmd *cobra.Command, args []string) error {
303 if err := f.readNotes(cmd); err != nil {
304 return err
305 }
306 paths := []string{}
307 for _, p := range [][2]string{{"title", "title"}, {"notes", "notes"}, {"notes-file", "notes"}, {"tag", "tag"}, {"target", "target_commitish"}, {"draft", "draft"}, {"publish", "draft"}, {"prerelease", "prerelease"}, {"no-prerelease", "prerelease"}, {"latest", "make_latest"}} {
308 if cmd.Flags().Changed(p[0]) {
309 paths = append(paths, p[1])
310 }
311 }
312 if len(paths) == 0 {
313 return errors.New("give at least one field to edit")
314 }
315 c, err := releaseClient(env, cmd)
316 if err != nil {
317 return err
318 }
319 ctx := env.ctx(cmd)
320 r, err := getRelease(ctx, c, args[0], args[1], false)
321 if err != nil {
322 return err
323 }
324 res, err := c.Releases().UpdateRelease(ctx, connect.NewRequest(&ermite.UpdateReleaseRequest{Repo: args[0], Id: r.GetId(), Tag: f.tag, Title: f.title, Notes: f.notes, TargetCommitish: f.target, Draft: f.draft && !f.publish, Prerelease: f.prerelease && !f.noPrerelease, MakeLatest: f.latest, UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}}))
325 if err != nil {
326 return c.Describe(err)
327 }
328 return outputRelease(cmd, c, res.Msg.GetRelease(), false)
329 }
330 return cmd
331}
332func newReleaseDeleteCmd(env *cliEnv) *cobra.Command {
333 var yes bool
334 cmd := releaseCommand(env, "delete <repo> <tag|id>", "Delete a release and its assets (keeps the tag)", 2)
335 cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip confirmation")
336 cmd.RunE = func(cmd *cobra.Command, args []string) error {
337 c, err := releaseClient(env, cmd)
338 if err != nil {
339 return err
340 }
341 ctx := env.ctx(cmd)
342 r, err := getRelease(ctx, c, args[0], args[1], false)
343 if err != nil {
344 return err
345 }
346 if in, ok := cmd.InOrStdin().(*os.File); !yes && ok && term.IsTerminal(int(in.Fd())) {
347 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Delete release %q and all its assets? [y/N] ", clean(r.GetTag()))
348 answer, err := bufio.NewReader(in).ReadString('\n')
349 if err != nil || !strings.EqualFold(strings.TrimSpace(answer), "y") {
350 return errors.New("aborted")
351 }
352 }
353 _, err = c.Releases().DeleteRelease(ctx, connect.NewRequest(&ermite.DeleteReleaseRequest{Repo: args[0], Id: r.GetId()}))
354 if err != nil {
355 return c.Describe(err)
356 }
357 _, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted release %d\n", r.GetId())
358 return err
359 }
360 return cmd
361}
362func newReleaseUploadCmd(env *cliEnv) *cobra.Command {
363 var clobber bool
364 var contentType string
365 cmd := releaseCommand(env, "upload <repo> <tag|id> FILE[#LABEL]...", "Upload release assets", 3)
366 cmd.Args = cobra.MinimumNArgs(3)
367 cmd.Flags().BoolVar(&clobber, "clobber", false, "replace assets with the same name")
368 cmd.Flags().StringVar(&contentType, "content-type", "", "override detected media type")
369 cmd.RunE = func(cmd *cobra.Command, args []string) error {
370 c, err := releaseClient(env, cmd)
371 if err != nil {
372 return err
373 }
374 ctx := env.ctx(cmd)
375 r, err := getRelease(ctx, c, args[0], args[1], false)
376 if err != nil {
377 return err
378 }
379 for _, spec := range args[2:] {
380 a, err := uploadReleaseFile(ctx, c, args[0], r.GetId(), spec, contentType, clobber)
381 if err != nil {
382 return err
383 }
384 noticef(cmd, "uploaded %s (%s)", clean(a.GetName()), humanBytes(a.GetSize()))
385 }
386 return nil
387 }
388 return cmd
389}
390func uploadReleaseFile(ctx context.Context, c *client.Client, repo string, id int64, spec, contentType string, replace bool) (*ermite.ReleaseAsset, error) {
391 filename, label, _ := strings.Cut(spec, "#")
392 f, err := os.Open(filename)
393 if err != nil {
394 return nil, err
395 }
396 defer func() { _ = f.Close() }()
397 info, err := f.Stat()
398 if err != nil {
399 return nil, err
400 }
401 if !info.Mode().IsRegular() {
402 return nil, fmt.Errorf("%q is not a regular file", filename)
403 }
404 if contentType == "" {
405 contentType = mime.TypeByExtension(filepath.Ext(filename))
406 if contentType == "" {
407 buf := make([]byte, 512)
408 n, err := f.Read(buf)
409 if err != nil && !errors.Is(err, io.EOF) {
410 return nil, err
411 }
412 contentType = http.DetectContentType(buf[:n])
413 if _, err := f.Seek(0, io.SeekStart); err != nil {
414 return nil, err
415 }
416 }
417 }
418 if _, _, err := mime.ParseMediaType(contentType); err != nil {
419 return nil, fmt.Errorf("invalid --content-type: %w", err)
420 }
421 name := filepath.Base(filename)
422 stream := c.Releases().UploadReleaseAsset(ctx)
423 // Always close the stream, including a local read or Send failure.
424 defer func() {
425 if conn, err := stream.Conn(); err == nil {
426 _ = conn.CloseRequest()
427 _ = conn.CloseResponse()
428 }
429 }()
430 err = stream.Send(&ermite.UploadReleaseAssetRequest{Repo: repo, ReleaseId: id, Name: name, Label: label, ContentType: contentType, Size: info.Size(), Replace: replace})
431 buf := make([]byte, 1<<20)
432 for err == nil {
433 n, rerr := f.Read(buf)
434 if n > 0 {
435 err = stream.Send(&ermite.UploadReleaseAssetRequest{Data: buf[:n]})
436 }
437 if rerr != nil {
438 if !errors.Is(rerr, io.EOF) {
439 return nil, rerr
440 }
441 break
442 }
443 }
444 // CloseAndReceive exposes a server error even when Send returned EOF.
445 res, closeErr := stream.CloseAndReceive()
446 if closeErr != nil {
447 err = closeErr
448 }
449 if err != nil {
450 if connect.CodeOf(err) == connect.CodeAlreadyExists && !replace {
451 return nil, fmt.Errorf("asset %q already exists; use --clobber to replace it", clean(name))
452 }
453 return nil, c.Describe(err)
454 }
455 return res.Msg.GetAsset(), nil
456}
457func newReleaseDownloadCmd(env *cliEnv) *cobra.Command {
458 var names, patterns []string
459 var dir string
460 cmd := releaseCommand(env, "download <repo> <tag|id|latest>", "Download assets without overwriting existing files", 2)
461 cmd.Flags().StringArrayVar(&names, "name", nil, "asset name (repeatable)")
462 cmd.Flags().StringArrayVar(&patterns, "pattern", nil, "asset name glob (repeatable)")
463 cmd.Flags().StringVarP(&dir, "dir", "D", ".", "download directory")
464 cmd.RunE = func(cmd *cobra.Command, args []string) error {
465 for _, p := range patterns {
466 if _, err := filepath.Match(p, ""); err != nil {
467 return fmt.Errorf("--pattern: %w", err)
468 }
469 }
470 c, err := releaseClient(env, cmd)
471 if err != nil {
472 return err
473 }
474 ctx := env.ctx(cmd)
475 r, err := getRelease(ctx, c, args[0], args[1], true)
476 if err != nil {
477 return err
478 }
479 picked, err := pickReleaseAssets(r.GetAssets(), names, patterns)
480 if err != nil {
481 return err
482 }
483 if len(picked) == 0 {
484 return errors.New("no matching release assets")
485 }
486 for _, a := range picked {
487 if err := checkArtifactName(a.GetName()); err != nil {
488 return err
489 }
490 }
491 if err := os.MkdirAll(dir, 0o755); err != nil {
492 return err
493 }
494 root, err := os.OpenRoot(dir)
495 if err != nil {
496 return err
497 }
498 defer func() { _ = root.Close() }()
499 for _, a := range picked {
500 if err := downloadReleaseAsset(ctx, c, args[0], a, root); err != nil {
501 return fmt.Errorf("asset %q: %w", clean(a.GetName()), err)
502 }
503 noticef(cmd, "downloaded %s to %s", clean(a.GetName()), clean(filepath.Join(dir, a.GetName())))
504 }
505 return nil
506 }
507 return cmd
508}
509func pickReleaseAssets(all []*ermite.ReleaseAsset, names, patterns []string) ([]*ermite.ReleaseAsset, error) {
510 for _, name := range names {
511 if !slices.ContainsFunc(all, func(a *ermite.ReleaseAsset) bool { return a.GetName() == name }) {
512 return nil, fmt.Errorf("the release has no asset %q", clean(name))
513 }
514 }
515 out := []*ermite.ReleaseAsset{}
516 for _, a := range all {
517 match := len(names) == 0 && len(patterns) == 0 || slices.Contains(names, a.GetName())
518 for _, p := range patterns {
519 ok, err := filepath.Match(p, a.GetName())
520 if err != nil {
521 return nil, err
522 }
523 match = match || ok
524 }
525 if match {
526 out = append(out, a)
527 }
528 }
529 return out, nil
530}
531
532// downloadReleaseAsset owns the destination exclusively; errors remove it. Only
533// transport failures resume, once, using the immutable asset id and kept offset.
534func downloadReleaseAsset(ctx context.Context, c *client.Client, repo string, a *ermite.ReleaseAsset, root *os.Root) (err error) {
535 f, err := root.OpenFile(a.GetName(), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
536 if err != nil {
537 return err
538 }
539 defer func() {
540 cerr := f.Close()
541 if err == nil {
542 err = cerr
543 }
544 if err != nil {
545 _ = root.Remove(a.GetName())
546 }
547 }()
548 hash := sha256.New()
549 w := io.MultiWriter(f, hash)
550 var kept int64
551 for attempt := 0; attempt < 2; attempt++ {
552 stream, openErr := c.Releases().DownloadReleaseAsset(ctx, connect.NewRequest(&ermite.DownloadReleaseAssetRequest{Repo: repo, Selector: &ermite.DownloadReleaseAssetRequest_AssetId{AssetId: a.GetId()}, Offset: kept}))
553 if openErr != nil {
554 err = openErr
555 } else {
556 described := false
557 for stream.Receive() {
558 msg := stream.Msg()
559 if !described {
560 meta := msg.GetAsset()
561 if meta == nil || meta.GetId() != a.GetId() || meta.GetSize() != a.GetSize() || meta.GetDigest() != a.GetDigest() {
562 _ = stream.Close()
563 return errors.New("the server did not describe the expected asset")
564 }
565 described = true
566 }
567 chunk := msg.GetChunk()
568 if kept+int64(len(chunk)) > a.GetSize() {
569 _ = stream.Close()
570 return errors.New("the server sent more than the asset size")
571 }
572 n, werr := w.Write(chunk)
573 kept += int64(n)
574 if werr != nil {
575 _ = stream.Close()
576 return werr
577 }
578 }
579 err = stream.Err()
580 _ = stream.Close()
581 if err == nil && (!described || kept != a.GetSize()) {
582 err = io.ErrUnexpectedEOF
583 }
584 }
585 if err == nil {
586 break
587 }
588 if ctx.Err() != nil {
589 return ctx.Err()
590 }
591 code := connect.CodeOf(err)
592 if attempt == 1 || (code != connect.CodeUnavailable && code != connect.CodeInternal && !errors.Is(err, io.ErrUnexpectedEOF)) {
593 return c.Describe(err)
594 }
595 }
596 if err != nil {
597 return c.Describe(err)
598 }
599 if a.GetDigest() != "sha256:"+hex.EncodeToString(hash.Sum(nil)) {
600 return errors.New("asset digest mismatch")
601 }
602 return f.Sync()
603}