···9999 return 0, errors.New("not an AppImage (missing ELF header)")
100100 }
101101102102- // Byte 8-10: AppImage type. 1 = ISO 9660 (unsupported), 2 = appended filesystem.
102102+ // Type-1 AppImages use ISO 9660 instead of an appended filesystem.
103103 if header[8] == 'A' && header[9] == 'I' && header[10] == 1 {
104104 return 0, errors.New("type-1 AppImages are not supported")
105105 }
···109109 endian = binary.BigEndian
110110 }
111111112112- // e_shoff + e_shnum * e_shentsize = end of the section header table.
112112+ // The payload starts after the section header table.
113113 switch header[4] {
114114 case 1: // 32-bit
115115 tableStart := int64(endian.Uint32(header[32:36]))
+2-2
internal/appherder/appimage_test.go
···350350 t.Fatal(err)
351351 }
352352353353- // Seed 3 existing versions with distinct mtimes.
353353+ // Seed existing versions with distinct mtimes.
354354 t1 := time.Now().Add(-3 * time.Hour)
355355 t2 := time.Now().Add(-2 * time.Hour)
356356 t3 := time.Now().Add(-1 * time.Hour)
···372372 t.Fatal(err)
373373 }
374374375375- // Saving a 4th version should prune v1 (oldest)
375375+ // Saving another version prunes v1, the oldest.
376376 if err := a.saveToVersions(current, "foo"); err != nil {
377377 t.Fatal(err)
378378 }
+3-5
internal/appherder/http.go
···3535 downloadIdleTimeout = 60 * time.Second
3636)
37373838-// idleTimeoutReader cancels via cancel() when a single Read stalls longer than
3939-// timeout, guarding a download against a connection that goes quiet.
3838+// idleTimeoutReader cancels stalled downloads without limiting total duration.
4039type idleTimeoutReader struct {
4140 reader io.Reader
4241 timer *time.Timer
···5655 return bytesRead, err
5756}
58575959-// httpGetOK sends a GET request and returns the response when the server
6060-// returns 200, closing the body and returning an error otherwise. customize
6161-// may set headers before the request is sent. The caller closes resp.Body.
5858+// httpGetOK returns a 200 response, closing the body before returning errors.
5959+// customize may add request headers. The caller closes resp.Body on success.
6260func httpGetOK(ctx context.Context, url, desc string, customize func(*http.Request)) (*http.Response, error) {
6361 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
6462 if err != nil {
+5-11
internal/appherder/install.go
···2020 return "", fmt.Errorf("resolve AppImage path %q: %w", appimage, err)
2121 }
22222323- // Verify before openAppImage: the DwarFS fallback executes the AppImage to
2424- // extract it, so bad images must be refused first. Pinned-key check is
2525- // deferred until the app name is known from the desktop file inside.
2323+ // Verify before openAppImage, because DwarFS extraction executes the AppImage.
2424+ // Defer pinned-key checks until the desktop file reveals the app name.
2625 fingerprint, err := verifyAppImage(appimage, "", want)
2726 if err != nil {
2827 return "", err
···4948 icon := resolveIcon(fsys)
5049 appName = deriveAppName(desktop, desktopName, appimage)
51505252- // Pinned-key check deferred from above; app name now known.
5351 pinned := a.pinnedSigningKey(appName)
5452 if pinned != "" {
5553 if fingerprint == "" {
···7270 iconPath = preparedIcon.path
7371 }
74727575- // No desktop file inside the AppImage: synthesize a terminal launcher so
7676- // CLI apps still get a menu entry and are tracked by managedApps.
7373+ // CLI AppImages often omit desktop files; synthesize one so Sync can track them.
7774 if desktop == nil {
7875 desktop = desktopfile.Parse([]byte(fmt.Sprintf(
7976 "[Desktop Entry]\nType=Application\nName=%s\nTerminal=true\n",
···8178 )))
8279 }
83808484- // Patch in memory before any filesystem writes so a failure here installs nothing.
8181+ // Patch before filesystem writes so a failure installs nothing.
8582 if err := a.patchDesktopFile(desktop, appName, iconPath); err != nil {
8683 return "", err
8784 }
···8986 desktop.Set(desktopEntrySection, desktopSigningKey, pin)
9087 }
91889292- // Roll back written files on a later failure rather than leaving a half-installed app.
9389 var installed []string
9490 rollback := func() {
9591 for _, path := range installed {
···113109 }
114110 installed = append(installed, dest)
115111116116- // Materialize the AppImage last: when the source is already in ~/AppImages
117117- // it gets moved, so an earlier failure must not roll back over the user's
118118- // file.
112112+ // Install the AppImage last so earlier failures never remove the user's source file.
119113 if _, err := a.installAppImage(appimage, appName); err != nil {
120114 rollback()
121115 return "", err
+2-2
internal/appherder/rollback_test.go
···103103 t.Fatal(err)
104104 }
105105106106- // Current should now be "old"
106106+ // The restored version becomes current.
107107 got, _ := os.ReadFile(current)
108108 if string(got) != "old" {
109109 t.Fatalf("current = %q, want old", string(got))
110110 }
111111112112- // Previous current should be saved; the restored version moves to active.
112112+ // The previous current file is saved as the only remaining version.
113113 entries, _ := os.ReadDir(versionsDir)
114114 if len(entries) != 1 {
115115 t.Fatalf("expected 1 saved version (pre-rollback current), got %d", len(entries))
+2-5
internal/appherder/signature.go
···42424343func (c expectedChecksum) set() bool { return c.hex != "" }
44444545-// matches reports whether the bytes fed to c.hasher hash to the advertised value.
4545+// matches reports whether c.hasher matches the advertised checksum.
4646func (c expectedChecksum) matches() bool {
4747 return strings.EqualFold(hex.EncodeToString(c.hasher.Sum(nil)), c.hex)
4848}
···6262 return bytes.TrimRight(data, "\x00"), byteRange{int64(section.Offset), int64(section.Size)}, true, nil
6363}
64646565-// readSignatureSections returns the .sha256_sig and .sig_key contents and the
6666-// byte ranges they occupy, which the signing digest zeroes.
6565+// readSignatureSections returns the signature sections and their file spans.
6766func readSignatureSections(file string) (sig, key []byte, zero []byteRange, err error) {
6867 elfFile, err := elf.Open(file)
6968 if err != nil {
···149148 }
150149 signed := len(bytes.TrimSpace(sig)) > 0
151150152152- // Single read pass: hash only what we check. rawHash takes the unmodified
153153- // bytes for the checksum, signHash the section-zeroed bytes for the digest.
154151 var rawHash, signHash hash.Hash
155152 if want.set() {
156153 rawHash = want.hasher
+5-10
internal/appherder/source.go
···2222type Release struct {
2323 Version string // human label, e.g. a release tag
2424 URL string // download URL for the AppImage
2525- SHA256 string // hex sha256 of the asset, "" when unavailable
2626- SHA1 string // hex sha1 (zsync's hash), "" when unavailable
2525+ SHA256 string // hex-encoded SHA-256, "" when unavailable
2626+ SHA1 string // hex-encoded zsync SHA-1, "" when unavailable
2727 Size int64 // content length, 0 when unavailable
2828 ModTime time.Time // server Last-Modified, for checksumless sources
2929}
···5555 if err != nil {
5656 return false, err
5757 }
5858- // No checksum: current if the install is at least as new as the server's
5959- // copy, else fall back to size.
6058 if !r.ModTime.IsZero() {
6159 return !info.ModTime().Before(r.ModTime), nil
6260 }
···148146 return parseUpdateInfo(info)
149147}
150148151151-// parseUpdateInfo turns an AppImage update-info string (the "type|a|b|..." form)
149149+// parseUpdateInfo turns an AppImage update-info string ("type|arg|arg|...")
152150// into a concrete source.
153151func parseUpdateInfo(info string) (Source, error) {
154152 fields := strings.Split(info, "|")
155153 switch fields[0] {
156154 case "gh-releases-zsync", "gh-releases-direct":
157157- // gh-releases-zsync|owner|repo|tag|pattern.zsync
158155 if len(fields) != 5 {
159156 return nil, fmt.Errorf("malformed GitHub update info %q", info)
160157 }
···165162 pattern: strings.TrimSuffix(fields[4], ".zsync"),
166163 }, nil
167164 case "gl-releases-zsync", "gl-releases-direct":
168168- // gl-releases-zsync|host|project|tag|pattern.zsync (our convention)
169165 if len(fields) != 5 {
170166 return nil, fmt.Errorf("malformed GitLab update info %q", info)
171167 }
···176172 pattern: strings.TrimSuffix(fields[4], ".zsync"),
177173 }, nil
178174 case "zsync":
179179- // zsync|https://host/path/App-latest.AppImage.zsync
180175 if len(fields) != 2 || fields[1] == "" {
181176 return nil, fmt.Errorf("malformed zsync update info %q", info)
182177 }
183178 return zsyncURLSource{url: fields[1]}, nil
184179 case "static":
185185- // static|https://host/App-latest.AppImage (our convention)
186180 if len(fields) != 2 || fields[1] == "" {
187181 return nil, fmt.Errorf("malformed static update info %q", info)
188182 }
···219213 return matches[0], nil
220214}
221215222222-const apiResponseLimit = 4 * 1024 * 1024 // 4 MiB; real API responses are a few KB
216216+// apiResponseLimit protects JSON and zsync parsers from unexpectedly large responses.
217217+const apiResponseLimit = 4 * 1024 * 1024
223218224219// decodeJSON decodes a JSON value from r, wrapping any error with desc.
225220func decodeJSON[T any](r io.Reader, desc string) (T, error) {
+3-4
internal/appherder/source_gitlab.go
···3636 if base == "" {
3737 base = "https://" + s.host
3838 }
3939- // The project path goes in one segment, so its slashes must be encoded.
3939+ // Encode slashes because GitLab expects the project path in one segment.
4040 endpoint := fmt.Sprintf("%s/api/v4/projects/%s/releases/", base, url.PathEscape(s.project))
4141 if s.tag == "" || s.tag == "latest" {
4242 endpoint += "permalink/latest"
···6969 }
7070 out := Release{Version: rel.TagName, URL: linkURL(asset)}
71717272- // No digest from the API: take the checksum from the sibling .zsync asset
7373- // when present, else comparison falls back to size.
7272+ // GitLab release links have no digest; sibling .zsync assets carry SHA-1.
7473 if zsyncLink, ok := findLink(rel.Assets.Links, asset.Name+".zsync"); ok {
7574 header, err := fetchZsyncHeader(ctx, linkURL(zsyncLink))
7675 if err != nil {
···9392 return glLink{}, false
9493}
95949696-// linkURL prefers the permalinked direct_asset_url, falling back to the raw url.
9595+// linkURL prefers GitLab's permalinked asset URL.
9796func linkURL(link glLink) string {
9897 if link.DirectAssetURL != "" {
9998 return link.DirectAssetURL
+3-4
internal/appherder/source_static.go
···88)
991010// staticURLSource tracks a fixed URL that always serves the latest AppImage.
1111-// There's no version or checksum, so freshness leans on the server's
1212-// Last-Modified (vs. the installed file's mtime), then Content-Length.
1111+// Without a version or checksum, freshness uses Last-Modified, then Content-Length.
1312type staticURLSource struct {
1413 url string
1514}
···3938 return rel, nil
4039}
41404242-// probe reads the URL's headers via HEAD, falling back to GET for servers that
4343-// reject it. The caller closes the body; we never read it.
4141+// probe reads headers with HEAD, falling back to GET for servers that reject it.
4242+// The caller closes the response body.
4443func (s staticURLSource) probe(ctx context.Context) (*http.Response, error) {
4544 for _, method := range []string{http.MethodHead, http.MethodGet} {
4645 req, err := http.NewRequestWithContext(ctx, method, s.url, nil)
+4-6
internal/appherder/source_zsync.go
···4242 return rel, nil
4343}
44444545-// fetchZsyncHeader downloads a .zsync control file and returns its parsed
4646-// header. Reused wherever a checksum can be read from a sibling .zsync asset.
4545+// fetchZsyncHeader returns the parsed header from a .zsync control file.
4746func fetchZsyncHeader(ctx context.Context, zsyncURL string) (map[string]string, error) {
4847 ctx, cancel := context.WithTimeout(ctx, apiTimeout)
4948 defer cancel()
···6261 return header, nil
6362}
64636565-// parseZsyncHeader reads a .zsync file's text header: "Key: Value" lines ending
6666-// at the blank line that separates them from the binary checksum block. Keys
6767-// are lowercased. It stops at the blank line so the body isn't consumed.
6464+// parseZsyncHeader reads "Key: Value" lines until the binary checksum block.
6565+// Header keys are lowercased.
6866func parseZsyncHeader(reader io.Reader) (map[string]string, error) {
6967 header := make(map[string]string)
7068 scanner := bufio.NewScanner(reader)
7169 for scanner.Scan() {
7270 line := scanner.Text()
7373- if line == "" { // header ends; binary data follows
7171+ if line == "" {
7472 break
7573 }
7674 key, value, ok := strings.Cut(line, ":")