A shepherd for your Appimages.
0

Configure Feed

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

add support for zsync upgrades

Aly Raffauf (Jun 17, 2026, 9:41 PM EDT) f55101c7 4b96317c

+322 -33
+6 -1
cmd/appherder/files.go
··· 4 4 "bytes" 5 5 "crypto/sha256" 6 6 "errors" 7 + "hash" 7 8 "io" 8 9 "io/fs" 9 10 "os" ··· 83 84 } 84 85 85 86 func fileHash(path string) ([]byte, error) { 87 + return fileSum(path, sha256.New()) 88 + } 89 + 90 + // fileSum returns h's checksum over the file's contents. 91 + func fileSum(path string, h hash.Hash) ([]byte, error) { 86 92 f, err := os.Open(path) 87 93 if err != nil { 88 94 return nil, err 89 95 } 90 96 defer f.Close() 91 97 92 - h := sha256.New() 93 98 if _, err := io.Copy(h, f); err != nil { 94 99 return nil, err 95 100 }
+63 -1
cmd/appherder/source.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "crypto/sha1" 6 + "crypto/sha256" 5 7 "debug/elf" 8 + "encoding/hex" 6 9 "fmt" 10 + "hash" 11 + "os" 7 12 "strings" 8 13 ) 9 14 ··· 11 16 type release struct { 12 17 version string // human label, e.g. a release tag 13 18 url string // download URL for the AppImage 14 - sha256 string // hex sha256 of the asset, "" when the source can't provide it 19 + sha256 string // hex sha256 of the asset, "" when unavailable 20 + sha1 string // hex sha1 (zsync's hash), "" when unavailable 15 21 size int64 16 22 } 17 23 24 + // checksum returns the strongest hash the release carries with a hasher to 25 + // match; ok is false when the source provided no cryptographic hash. 26 + func (r release) checksum() (want string, h hash.Hash, ok bool) { 27 + switch { 28 + case r.sha256 != "": 29 + return r.sha256, sha256.New(), true 30 + case r.sha1 != "": 31 + return r.sha1, sha1.New(), true 32 + } 33 + return "", nil, false 34 + } 35 + 36 + // localMatches reports whether file already equals this release, by the 37 + // strongest checksum available and falling back to size (a weak signal that 38 + // only distinguishes differently-sized builds). 39 + func (r release) localMatches(file string) (bool, error) { 40 + if want, h, ok := r.checksum(); ok { 41 + sum, err := fileSum(file, h) 42 + if err != nil { 43 + return false, err 44 + } 45 + return strings.EqualFold(hex.EncodeToString(sum), want), nil 46 + } 47 + if r.size > 0 { 48 + info, err := os.Stat(file) 49 + if err != nil { 50 + return false, err 51 + } 52 + return info.Size() == r.size, nil 53 + } 54 + return false, nil 55 + } 56 + 57 + // verifyDownload checks a freshly downloaded file against the release's 58 + // checksum. With no checksum there is nothing to verify and it returns nil. 59 + func (r release) verifyDownload(file string) error { 60 + want, h, ok := r.checksum() 61 + if !ok { 62 + return nil 63 + } 64 + sum, err := fileSum(file, h) 65 + if err != nil { 66 + return err 67 + } 68 + if !strings.EqualFold(hex.EncodeToString(sum), want) { 69 + return fmt.Errorf("downloaded AppImage failed checksum verification") 70 + } 71 + return nil 72 + } 73 + 18 74 // source resolves the latest available build of an installed app. 19 75 type source interface { 20 76 latest(ctx context.Context) (release, error) ··· 69 125 tag: fields[3], 70 126 pattern: strings.TrimSuffix(fields[4], ".zsync"), 71 127 }, nil 128 + case "zsync": 129 + // zsync|https://host/path/App-latest.AppImage.zsync 130 + if len(fields) != 2 || fields[1] == "" { 131 + return nil, fmt.Errorf("malformed zsync update info %q", info) 132 + } 133 + return zsyncURLSource{url: fields[1]}, nil 72 134 default: 73 135 return nil, fmt.Errorf("unsupported update source %q", fields[0]) 74 136 }
+33 -1
cmd/appherder/source_test.go
··· 1 1 package main 2 2 3 - import "testing" 3 + import ( 4 + "crypto/sha1" 5 + "encoding/hex" 6 + "os" 7 + "path/filepath" 8 + "testing" 9 + ) 10 + 11 + func TestReleaseLocalMatchesSHA1(t *testing.T) { 12 + dir := t.TempDir() 13 + file := filepath.Join(dir, "app.AppImage") 14 + content := []byte("hello zsync") 15 + if err := os.WriteFile(file, content, 0o644); err != nil { 16 + t.Fatal(err) 17 + } 18 + sum := sha1.Sum(content) 19 + 20 + rel := release{sha1: hex.EncodeToString(sum[:])} 21 + if ok, err := rel.localMatches(file); err != nil || !ok { 22 + t.Fatalf("localMatches = %v, %v; want true", ok, err) 23 + } 24 + if err := rel.verifyDownload(file); err != nil { 25 + t.Fatalf("verifyDownload = %v; want nil", err) 26 + } 27 + 28 + stale := release{sha1: hex.EncodeToString(make([]byte, 20))} 29 + if ok, _ := stale.localMatches(file); ok { 30 + t.Fatal("localMatches = true for mismatched sha1") 31 + } 32 + if err := stale.verifyDownload(file); err == nil { 33 + t.Fatal("verifyDownload = nil for mismatched sha1") 34 + } 35 + } 4 36 5 37 func TestParseUpdateInfoGitHubStripsZsyncSuffix(t *testing.T) { 6 38 src, err := parseUpdateInfo("gh-releases-zsync|myorg|myapp|latest|MyApp-*-x86_64.AppImage.zsync")
+110
cmd/appherder/source_zsync.go
··· 1 + package main 2 + 3 + import ( 4 + "bufio" 5 + "context" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "net/url" 10 + "strconv" 11 + "strings" 12 + ) 13 + 14 + // zsyncURLSource resolves updates from a .zsync control file at a fixed URL, 15 + // the generic (non-GitHub) AppImage update mechanism. We read the control 16 + // file's header for the target's size, checksum, and URL, then download the 17 + // whole file; zsync's block-level delta transfer isn't implemented yet. 18 + type zsyncURLSource struct { 19 + url string 20 + } 21 + 22 + func (s zsyncURLSource) latest(ctx context.Context) (release, error) { 23 + ctx, cancel := context.WithTimeout(ctx, apiTimeout) 24 + defer cancel() 25 + 26 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.url, nil) 27 + if err != nil { 28 + return release{}, err 29 + } 30 + resp, err := httpClient.Do(req) 31 + if err != nil { 32 + return release{}, fmt.Errorf("fetch zsync control file %s: %w", s.url, err) 33 + } 34 + defer resp.Body.Close() 35 + if resp.StatusCode != http.StatusOK { 36 + return release{}, fmt.Errorf("fetch zsync control file %s: %s", s.url, resp.Status) 37 + } 38 + 39 + header, err := parseZsyncHeader(resp.Body) 40 + if err != nil { 41 + return release{}, fmt.Errorf("parse zsync control file %s: %w", s.url, err) 42 + } 43 + 44 + target, err := resolveZsyncURL(s.url, header["url"]) 45 + if err != nil { 46 + return release{}, err 47 + } 48 + 49 + rel := release{ 50 + url: target, 51 + sha1: header["sha-1"], 52 + version: zsyncVersion(header), 53 + } 54 + if n, err := strconv.ParseInt(header["length"], 10, 64); err == nil { 55 + rel.size = n 56 + } 57 + return rel, nil 58 + } 59 + 60 + // parseZsyncHeader reads a .zsync file's text header: "Key: Value" lines ending 61 + // at the blank line that separates them from the binary checksum block. Keys 62 + // are lowercased. It stops at the blank line so the body isn't consumed. 63 + func parseZsyncHeader(r io.Reader) (map[string]string, error) { 64 + header := make(map[string]string) 65 + scanner := bufio.NewScanner(r) 66 + for scanner.Scan() { 67 + line := scanner.Text() 68 + if line == "" { // header ends; binary data follows 69 + break 70 + } 71 + key, value, ok := strings.Cut(line, ":") 72 + if !ok { 73 + return nil, fmt.Errorf("malformed header line %q", line) 74 + } 75 + header[strings.ToLower(strings.TrimSpace(key))] = strings.TrimSpace(value) 76 + } 77 + if err := scanner.Err(); err != nil { 78 + return nil, err 79 + } 80 + if header["url"] == "" { 81 + return nil, fmt.Errorf("control file has no URL field") 82 + } 83 + return header, nil 84 + } 85 + 86 + // resolveZsyncURL turns the control file's URL field, which is often relative, 87 + // into an absolute download URL against the .zsync file's own location. 88 + func resolveZsyncURL(zsyncURL, target string) (string, error) { 89 + base, err := url.Parse(zsyncURL) 90 + if err != nil { 91 + return "", fmt.Errorf("parse zsync URL %s: %w", zsyncURL, err) 92 + } 93 + ref, err := url.Parse(target) 94 + if err != nil { 95 + return "", fmt.Errorf("parse target URL %q: %w", target, err) 96 + } 97 + return base.ResolveReference(ref).String(), nil 98 + } 99 + 100 + // zsyncVersion picks a human label for the build. zsync carries no version, so 101 + // MTime (which changes per build) is the most useful, then the filename. 102 + func zsyncVersion(header map[string]string) string { 103 + if v := header["mtime"]; v != "" { 104 + return v 105 + } 106 + if v := header["filename"]; v != "" { 107 + return v 108 + } 109 + return "latest" 110 + }
+107
cmd/appherder/source_zsync_test.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + "net/http/httptest" 7 + "strings" 8 + "testing" 9 + ) 10 + 11 + const sampleZsync = "zsync: 0.6.2\n" + 12 + "Filename: MyApp-x86_64.AppImage\n" + 13 + "MTime: Wed, 01 Jan 2025 00:00:00 +0000\n" + 14 + "Blocksize: 2048\n" + 15 + "Length: 4096\n" + 16 + "Hash-Lengths: 2,2,4\n" + 17 + "URL: MyApp-x86_64.AppImage\n" + 18 + "SHA-1: da39a3ee5e6b4b0d3255bfef95601890afd80709\n" + 19 + "\n\x00\x01\x02\x03binary checksum block" 20 + 21 + func TestParseUpdateInfoZsync(t *testing.T) { 22 + src, err := parseUpdateInfo("zsync|https://host/path/App.AppImage.zsync") 23 + if err != nil { 24 + t.Fatal(err) 25 + } 26 + z, ok := src.(zsyncURLSource) 27 + if !ok { 28 + t.Fatalf("got %T, want zsyncURLSource", src) 29 + } 30 + if z.url != "https://host/path/App.AppImage.zsync" { 31 + t.Fatalf("url = %q", z.url) 32 + } 33 + } 34 + 35 + func TestParseUpdateInfoRejectsMalformedZsync(t *testing.T) { 36 + if _, err := parseUpdateInfo("zsync|"); err == nil { 37 + t.Fatal("expected error for empty zsync URL") 38 + } 39 + } 40 + 41 + func TestParseZsyncHeaderStopsAtBlankLine(t *testing.T) { 42 + h, err := parseZsyncHeader(strings.NewReader(sampleZsync)) 43 + if err != nil { 44 + t.Fatal(err) 45 + } 46 + if h["length"] != "4096" { 47 + t.Fatalf("length = %q", h["length"]) 48 + } 49 + if h["sha-1"] != "da39a3ee5e6b4b0d3255bfef95601890afd80709" { 50 + t.Fatalf("sha-1 = %q", h["sha-1"]) 51 + } 52 + if h["mtime"] != "Wed, 01 Jan 2025 00:00:00 +0000" { // value keeps its colons 53 + t.Fatalf("mtime = %q", h["mtime"]) 54 + } 55 + } 56 + 57 + func TestParseZsyncHeaderRequiresURL(t *testing.T) { 58 + if _, err := parseZsyncHeader(strings.NewReader("Length: 1\n\n")); err == nil { 59 + t.Fatal("expected error when URL field is missing") 60 + } 61 + } 62 + 63 + func TestResolveZsyncURL(t *testing.T) { 64 + got, err := resolveZsyncURL("https://host/dir/App.AppImage.zsync", "App-x86_64.AppImage") 65 + if err != nil { 66 + t.Fatal(err) 67 + } 68 + if got != "https://host/dir/App-x86_64.AppImage" { 69 + t.Fatalf("relative resolution = %q", got) 70 + } 71 + 72 + got, err = resolveZsyncURL("https://host/dir/App.zsync", "https://cdn.example/App.AppImage") 73 + if err != nil { 74 + t.Fatal(err) 75 + } 76 + if got != "https://cdn.example/App.AppImage" { 77 + t.Fatalf("absolute URL = %q", got) 78 + } 79 + } 80 + 81 + func TestZsyncURLSourceLatest(t *testing.T) { 82 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 83 + if r.URL.Path != "/app/MyApp.AppImage.zsync" { 84 + t.Errorf("path = %s", r.URL.Path) 85 + } 86 + w.Write([]byte(sampleZsync)) 87 + })) 88 + defer srv.Close() 89 + 90 + src := zsyncURLSource{url: srv.URL + "/app/MyApp.AppImage.zsync"} 91 + rel, err := src.latest(context.Background()) 92 + if err != nil { 93 + t.Fatal(err) 94 + } 95 + if rel.url != srv.URL+"/app/MyApp-x86_64.AppImage" { 96 + t.Fatalf("url = %q, want target resolved against control file", rel.url) 97 + } 98 + if rel.sha1 != "da39a3ee5e6b4b0d3255bfef95601890afd80709" { 99 + t.Fatalf("sha1 = %q", rel.sha1) 100 + } 101 + if rel.size != 4096 { 102 + t.Fatalf("size = %d", rel.size) 103 + } 104 + if rel.version != "Wed, 01 Jan 2025 00:00:00 +0000" { 105 + t.Fatalf("version = %q", rel.version) 106 + } 107 + }
+3 -30
cmd/appherder/upgrade.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "encoding/hex" 6 5 "fmt" 7 6 "io" 8 7 "net/http" ··· 90 89 return upgradeCheck{name: name, err: err} 91 90 } 92 91 93 - current, err := upToDate(file, rel) 92 + current, err := rel.localMatches(file) 94 93 if err != nil { 95 94 return upgradeCheck{name: name, err: err} 96 95 } ··· 119 118 return results 120 119 } 121 120 122 - // upToDate reports whether the installed file already matches the latest 123 - // release, preferring an exact sha256 check and falling back to size. 124 - func upToDate(file string, rel release) (bool, error) { 125 - if rel.sha256 != "" { 126 - sum, err := fileHash(file) 127 - if err != nil { 128 - return false, err 129 - } 130 - return strings.EqualFold(hex.EncodeToString(sum), rel.sha256), nil 131 - } 132 - if rel.size > 0 { 133 - info, err := os.Stat(file) 134 - if err != nil { 135 - return false, err 136 - } 137 - return info.Size() == rel.size, nil 138 - } 139 - return false, nil 140 - } 141 - 142 121 func (a app) applyUpgrade(ctx context.Context, rel release) error { 143 122 tmp, err := os.CreateTemp("", "appherder-upgrade-*.appimage") 144 123 if err != nil { ··· 155 134 return fmt.Errorf("close download: %w", err) 156 135 } 157 136 158 - if rel.sha256 != "" { 159 - sum, err := fileHash(tmpName) 160 - if err != nil { 161 - return err 162 - } 163 - if !strings.EqualFold(hex.EncodeToString(sum), rel.sha256) { 164 - return fmt.Errorf("downloaded AppImage failed sha256 verification") 165 - } 137 + if err := rel.verifyDownload(tmpName); err != nil { 138 + return err 166 139 } 167 140 168 141 return a.install(tmpName)