A shepherd for your Appimages.
0

Configure Feed

Select the types of activity you want to include in your feed.

clarify commnt docs

Aly Raffauf (Jul 5, 2026, 10:34 AM EDT) e0e8dba5 d197460a

+31 -51
+2 -2
internal/appherder/appimage.go
··· 99 99 return 0, errors.New("not an AppImage (missing ELF header)") 100 100 } 101 101 102 - // Byte 8-10: AppImage type. 1 = ISO 9660 (unsupported), 2 = appended filesystem. 102 + // Type-1 AppImages use ISO 9660 instead of an appended filesystem. 103 103 if header[8] == 'A' && header[9] == 'I' && header[10] == 1 { 104 104 return 0, errors.New("type-1 AppImages are not supported") 105 105 } ··· 109 109 endian = binary.BigEndian 110 110 } 111 111 112 - // e_shoff + e_shnum * e_shentsize = end of the section header table. 112 + // The payload starts after the section header table. 113 113 switch header[4] { 114 114 case 1: // 32-bit 115 115 tableStart := int64(endian.Uint32(header[32:36]))
+2 -2
internal/appherder/appimage_test.go
··· 350 350 t.Fatal(err) 351 351 } 352 352 353 - // Seed 3 existing versions with distinct mtimes. 353 + // Seed existing versions with distinct mtimes. 354 354 t1 := time.Now().Add(-3 * time.Hour) 355 355 t2 := time.Now().Add(-2 * time.Hour) 356 356 t3 := time.Now().Add(-1 * time.Hour) ··· 372 372 t.Fatal(err) 373 373 } 374 374 375 - // Saving a 4th version should prune v1 (oldest) 375 + // Saving another version prunes v1, the oldest. 376 376 if err := a.saveToVersions(current, "foo"); err != nil { 377 377 t.Fatal(err) 378 378 }
+3 -5
internal/appherder/http.go
··· 35 35 downloadIdleTimeout = 60 * time.Second 36 36 ) 37 37 38 - // idleTimeoutReader cancels via cancel() when a single Read stalls longer than 39 - // timeout, guarding a download against a connection that goes quiet. 38 + // idleTimeoutReader cancels stalled downloads without limiting total duration. 40 39 type idleTimeoutReader struct { 41 40 reader io.Reader 42 41 timer *time.Timer ··· 56 55 return bytesRead, err 57 56 } 58 57 59 - // httpGetOK sends a GET request and returns the response when the server 60 - // returns 200, closing the body and returning an error otherwise. customize 61 - // may set headers before the request is sent. The caller closes resp.Body. 58 + // httpGetOK returns a 200 response, closing the body before returning errors. 59 + // customize may add request headers. The caller closes resp.Body on success. 62 60 func httpGetOK(ctx context.Context, url, desc string, customize func(*http.Request)) (*http.Response, error) { 63 61 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 64 62 if err != nil {
+5 -11
internal/appherder/install.go
··· 20 20 return "", fmt.Errorf("resolve AppImage path %q: %w", appimage, err) 21 21 } 22 22 23 - // Verify before openAppImage: the DwarFS fallback executes the AppImage to 24 - // extract it, so bad images must be refused first. Pinned-key check is 25 - // deferred until the app name is known from the desktop file inside. 23 + // Verify before openAppImage, because DwarFS extraction executes the AppImage. 24 + // Defer pinned-key checks until the desktop file reveals the app name. 26 25 fingerprint, err := verifyAppImage(appimage, "", want) 27 26 if err != nil { 28 27 return "", err ··· 49 48 icon := resolveIcon(fsys) 50 49 appName = deriveAppName(desktop, desktopName, appimage) 51 50 52 - // Pinned-key check deferred from above; app name now known. 53 51 pinned := a.pinnedSigningKey(appName) 54 52 if pinned != "" { 55 53 if fingerprint == "" { ··· 72 70 iconPath = preparedIcon.path 73 71 } 74 72 75 - // No desktop file inside the AppImage: synthesize a terminal launcher so 76 - // CLI apps still get a menu entry and are tracked by managedApps. 73 + // CLI AppImages often omit desktop files; synthesize one so Sync can track them. 77 74 if desktop == nil { 78 75 desktop = desktopfile.Parse([]byte(fmt.Sprintf( 79 76 "[Desktop Entry]\nType=Application\nName=%s\nTerminal=true\n", ··· 81 78 ))) 82 79 } 83 80 84 - // Patch in memory before any filesystem writes so a failure here installs nothing. 81 + // Patch before filesystem writes so a failure installs nothing. 85 82 if err := a.patchDesktopFile(desktop, appName, iconPath); err != nil { 86 83 return "", err 87 84 } ··· 89 86 desktop.Set(desktopEntrySection, desktopSigningKey, pin) 90 87 } 91 88 92 - // Roll back written files on a later failure rather than leaving a half-installed app. 93 89 var installed []string 94 90 rollback := func() { 95 91 for _, path := range installed { ··· 113 109 } 114 110 installed = append(installed, dest) 115 111 116 - // Materialize the AppImage last: when the source is already in ~/AppImages 117 - // it gets moved, so an earlier failure must not roll back over the user's 118 - // file. 112 + // Install the AppImage last so earlier failures never remove the user's source file. 119 113 if _, err := a.installAppImage(appimage, appName); err != nil { 120 114 rollback() 121 115 return "", err
+2 -2
internal/appherder/rollback_test.go
··· 103 103 t.Fatal(err) 104 104 } 105 105 106 - // Current should now be "old" 106 + // The restored version becomes current. 107 107 got, _ := os.ReadFile(current) 108 108 if string(got) != "old" { 109 109 t.Fatalf("current = %q, want old", string(got)) 110 110 } 111 111 112 - // Previous current should be saved; the restored version moves to active. 112 + // The previous current file is saved as the only remaining version. 113 113 entries, _ := os.ReadDir(versionsDir) 114 114 if len(entries) != 1 { 115 115 t.Fatalf("expected 1 saved version (pre-rollback current), got %d", len(entries))
+2 -5
internal/appherder/signature.go
··· 42 42 43 43 func (c expectedChecksum) set() bool { return c.hex != "" } 44 44 45 - // matches reports whether the bytes fed to c.hasher hash to the advertised value. 45 + // matches reports whether c.hasher matches the advertised checksum. 46 46 func (c expectedChecksum) matches() bool { 47 47 return strings.EqualFold(hex.EncodeToString(c.hasher.Sum(nil)), c.hex) 48 48 } ··· 62 62 return bytes.TrimRight(data, "\x00"), byteRange{int64(section.Offset), int64(section.Size)}, true, nil 63 63 } 64 64 65 - // readSignatureSections returns the .sha256_sig and .sig_key contents and the 66 - // byte ranges they occupy, which the signing digest zeroes. 65 + // readSignatureSections returns the signature sections and their file spans. 67 66 func readSignatureSections(file string) (sig, key []byte, zero []byteRange, err error) { 68 67 elfFile, err := elf.Open(file) 69 68 if err != nil { ··· 149 148 } 150 149 signed := len(bytes.TrimSpace(sig)) > 0 151 150 152 - // Single read pass: hash only what we check. rawHash takes the unmodified 153 - // bytes for the checksum, signHash the section-zeroed bytes for the digest. 154 151 var rawHash, signHash hash.Hash 155 152 if want.set() { 156 153 rawHash = want.hasher
+5 -10
internal/appherder/source.go
··· 22 22 type Release struct { 23 23 Version string // human label, e.g. a release tag 24 24 URL string // download URL for the AppImage 25 - SHA256 string // hex sha256 of the asset, "" when unavailable 26 - SHA1 string // hex sha1 (zsync's hash), "" when unavailable 25 + SHA256 string // hex-encoded SHA-256, "" when unavailable 26 + SHA1 string // hex-encoded zsync SHA-1, "" when unavailable 27 27 Size int64 // content length, 0 when unavailable 28 28 ModTime time.Time // server Last-Modified, for checksumless sources 29 29 } ··· 55 55 if err != nil { 56 56 return false, err 57 57 } 58 - // No checksum: current if the install is at least as new as the server's 59 - // copy, else fall back to size. 60 58 if !r.ModTime.IsZero() { 61 59 return !info.ModTime().Before(r.ModTime), nil 62 60 } ··· 148 146 return parseUpdateInfo(info) 149 147 } 150 148 151 - // parseUpdateInfo turns an AppImage update-info string (the "type|a|b|..." form) 149 + // parseUpdateInfo turns an AppImage update-info string ("type|arg|arg|...") 152 150 // into a concrete source. 153 151 func parseUpdateInfo(info string) (Source, error) { 154 152 fields := strings.Split(info, "|") 155 153 switch fields[0] { 156 154 case "gh-releases-zsync", "gh-releases-direct": 157 - // gh-releases-zsync|owner|repo|tag|pattern.zsync 158 155 if len(fields) != 5 { 159 156 return nil, fmt.Errorf("malformed GitHub update info %q", info) 160 157 } ··· 165 162 pattern: strings.TrimSuffix(fields[4], ".zsync"), 166 163 }, nil 167 164 case "gl-releases-zsync", "gl-releases-direct": 168 - // gl-releases-zsync|host|project|tag|pattern.zsync (our convention) 169 165 if len(fields) != 5 { 170 166 return nil, fmt.Errorf("malformed GitLab update info %q", info) 171 167 } ··· 176 172 pattern: strings.TrimSuffix(fields[4], ".zsync"), 177 173 }, nil 178 174 case "zsync": 179 - // zsync|https://host/path/App-latest.AppImage.zsync 180 175 if len(fields) != 2 || fields[1] == "" { 181 176 return nil, fmt.Errorf("malformed zsync update info %q", info) 182 177 } 183 178 return zsyncURLSource{url: fields[1]}, nil 184 179 case "static": 185 - // static|https://host/App-latest.AppImage (our convention) 186 180 if len(fields) != 2 || fields[1] == "" { 187 181 return nil, fmt.Errorf("malformed static update info %q", info) 188 182 } ··· 219 213 return matches[0], nil 220 214 } 221 215 222 - const apiResponseLimit = 4 * 1024 * 1024 // 4 MiB; real API responses are a few KB 216 + // apiResponseLimit protects JSON and zsync parsers from unexpectedly large responses. 217 + const apiResponseLimit = 4 * 1024 * 1024 223 218 224 219 // decodeJSON decodes a JSON value from r, wrapping any error with desc. 225 220 func decodeJSON[T any](r io.Reader, desc string) (T, error) {
+3 -4
internal/appherder/source_gitlab.go
··· 36 36 if base == "" { 37 37 base = "https://" + s.host 38 38 } 39 - // The project path goes in one segment, so its slashes must be encoded. 39 + // Encode slashes because GitLab expects the project path in one segment. 40 40 endpoint := fmt.Sprintf("%s/api/v4/projects/%s/releases/", base, url.PathEscape(s.project)) 41 41 if s.tag == "" || s.tag == "latest" { 42 42 endpoint += "permalink/latest" ··· 69 69 } 70 70 out := Release{Version: rel.TagName, URL: linkURL(asset)} 71 71 72 - // No digest from the API: take the checksum from the sibling .zsync asset 73 - // when present, else comparison falls back to size. 72 + // GitLab release links have no digest; sibling .zsync assets carry SHA-1. 74 73 if zsyncLink, ok := findLink(rel.Assets.Links, asset.Name+".zsync"); ok { 75 74 header, err := fetchZsyncHeader(ctx, linkURL(zsyncLink)) 76 75 if err != nil { ··· 93 92 return glLink{}, false 94 93 } 95 94 96 - // linkURL prefers the permalinked direct_asset_url, falling back to the raw url. 95 + // linkURL prefers GitLab's permalinked asset URL. 97 96 func linkURL(link glLink) string { 98 97 if link.DirectAssetURL != "" { 99 98 return link.DirectAssetURL
+3 -4
internal/appherder/source_static.go
··· 8 8 ) 9 9 10 10 // staticURLSource tracks a fixed URL that always serves the latest AppImage. 11 - // There's no version or checksum, so freshness leans on the server's 12 - // Last-Modified (vs. the installed file's mtime), then Content-Length. 11 + // Without a version or checksum, freshness uses Last-Modified, then Content-Length. 13 12 type staticURLSource struct { 14 13 url string 15 14 } ··· 39 38 return rel, nil 40 39 } 41 40 42 - // probe reads the URL's headers via HEAD, falling back to GET for servers that 43 - // reject it. The caller closes the body; we never read it. 41 + // probe reads headers with HEAD, falling back to GET for servers that reject it. 42 + // The caller closes the response body. 44 43 func (s staticURLSource) probe(ctx context.Context) (*http.Response, error) { 45 44 for _, method := range []string{http.MethodHead, http.MethodGet} { 46 45 req, err := http.NewRequestWithContext(ctx, method, s.url, nil)
+4 -6
internal/appherder/source_zsync.go
··· 42 42 return rel, nil 43 43 } 44 44 45 - // fetchZsyncHeader downloads a .zsync control file and returns its parsed 46 - // header. Reused wherever a checksum can be read from a sibling .zsync asset. 45 + // fetchZsyncHeader returns the parsed header from a .zsync control file. 47 46 func fetchZsyncHeader(ctx context.Context, zsyncURL string) (map[string]string, error) { 48 47 ctx, cancel := context.WithTimeout(ctx, apiTimeout) 49 48 defer cancel() ··· 62 61 return header, nil 63 62 } 64 63 65 - // parseZsyncHeader reads a .zsync file's text header: "Key: Value" lines ending 66 - // at the blank line that separates them from the binary checksum block. Keys 67 - // are lowercased. It stops at the blank line so the body isn't consumed. 64 + // parseZsyncHeader reads "Key: Value" lines until the binary checksum block. 65 + // Header keys are lowercased. 68 66 func parseZsyncHeader(reader io.Reader) (map[string]string, error) { 69 67 header := make(map[string]string) 70 68 scanner := bufio.NewScanner(reader) 71 69 for scanner.Scan() { 72 70 line := scanner.Text() 73 - if line == "" { // header ends; binary data follows 71 + if line == "" { 74 72 break 75 73 } 76 74 key, value, ok := strings.Cut(line, ":")