11import { basename , dirname , join } from "path" ;
22import { mkdtemp , chmod , copyFile , rm , rename } from "fs/promises" ;
33import { tmpdir } from "os" ;
4+ import { createHash } from "crypto" ;
45
56const LATEST_RELEASE_URL = "https://api.github.com/repos/odefun/ode/releases/latest" ;
6- const DOWNLOAD_BASE_URL = "https://github.com/odefun/ode/releases/latest /download" ;
7+ const RELEASE_DOWNLOAD_BASE_URL = "https://github.com/odefun/ode/releases/download" ;
78
89type UpdateCheckResult = {
910 currentVersion : string ;
1011 latestVersion : string | null ;
1112 isUpdateAvailable : boolean ;
1213} ;
1314
15+ type LatestReleaseInfo = {
16+ tag : string ;
17+ version : string | null ;
18+ } ;
19+
1420function normalizeVersion ( version : string | null | undefined ) : string | null {
1521 if ( ! version ) return null ;
1622 const trimmed = version . trim ( ) . replace ( / ^ v / , "" ) ;
@@ -31,17 +37,40 @@ function compareVersions(a: string, b: string): number {
3137 return 0 ;
3238}
3339
34- async function fetchLatestVersion ( ) : Promise < string | null > {
40+ async function fetchLatestReleaseInfo ( ) : Promise < LatestReleaseInfo | null > {
3541 try {
3642 const latestResponse = await fetch ( LATEST_RELEASE_URL ) ;
3743 if ( ! latestResponse . ok ) return null ;
3844 const latest = ( await latestResponse . json ( ) ) as { tag_name ?: string } ;
39- return normalizeVersion ( latest . tag_name ?? null ) ;
45+ const tag = typeof latest . tag_name === "string" ? latest . tag_name . trim ( ) : "" ;
46+ if ( ! tag ) return null ;
47+ return {
48+ tag,
49+ version : normalizeVersion ( tag ) ,
50+ } ;
4051 } catch {
4152 return null ;
4253 }
4354}
4455
56+ function sha256Hex ( data : Uint8Array ) : string {
57+ return createHash ( "sha256" ) . update ( data ) . digest ( "hex" ) ;
58+ }
59+
60+ function parseSha256SumFile ( content : string , assetName : string ) : string | null {
61+ for ( const line of content . split ( / \r ? \n / ) ) {
62+ const trimmed = line . trim ( ) ;
63+ if ( ! trimmed ) continue ;
64+ const match = trimmed . match ( / ^ ( [ a - f A - F 0 - 9 ] { 64 } ) \s + \* ? ( .+ ) $ / ) ;
65+ if ( ! match ) continue ;
66+ const hash = match [ 1 ] ?. toLowerCase ( ) ;
67+ const fileName = basename ( ( match [ 2 ] ?? "" ) . trim ( ) ) ;
68+ if ( fileName !== assetName ) continue ;
69+ return hash ?? null ;
70+ }
71+ return null ;
72+ }
73+
4574function resolveAsset ( ) : string {
4675 const platform = process . platform ;
4776 const arch = process . arch ;
@@ -69,7 +98,8 @@ export function isInstalledBinary(): boolean {
6998
7099export async function checkForUpdate ( currentVersion : string ) : Promise < UpdateCheckResult > {
71100 const normalizedCurrent = normalizeVersion ( currentVersion ) ?? "0.0.0" ;
72- const latestVersion = await fetchLatestVersion ( ) ;
101+ const latestRelease = await fetchLatestReleaseInfo ( ) ;
102+ const latestVersion = latestRelease ?. version ?? null ;
73103 if ( ! latestVersion ) {
74104 return {
75105 currentVersion : normalizedCurrent ,
@@ -86,17 +116,43 @@ export async function checkForUpdate(currentVersion: string): Promise<UpdateChec
86116}
87117
88118export async function performUpgrade ( ) : Promise < { latestVersion : string | null } > {
89- const latestVersion = await fetchLatestVersion ( ) ;
119+ const latestRelease = await fetchLatestReleaseInfo ( ) ;
120+ if ( ! latestRelease ?. tag ) {
121+ throw new Error ( "Failed to resolve latest release tag" ) ;
122+ }
123+
124+ const latestVersion = latestRelease . version ;
90125 const asset = resolveAsset ( ) ;
91- const url = `${ DOWNLOAD_BASE_URL } /${ asset } ` ;
92- const response = await fetch ( url ) ;
93- if ( ! response . ok ) {
94- throw new Error ( `Failed to download ${ url } (${ response . status } )` ) ;
126+ const downloadBaseUrl = `${ RELEASE_DOWNLOAD_BASE_URL } /${ encodeURIComponent ( latestRelease . tag ) } ` ;
127+ const binaryUrl = `${ downloadBaseUrl } /${ asset } ` ;
128+ const checksumsUrl = `${ downloadBaseUrl } /SHA256SUMS` ;
129+
130+ const [ binaryResponse , checksumsResponse ] = await Promise . all ( [
131+ fetch ( binaryUrl ) ,
132+ fetch ( checksumsUrl ) ,
133+ ] ) ;
134+ if ( ! binaryResponse . ok ) {
135+ throw new Error ( `Failed to download ${ binaryUrl } (${ binaryResponse . status } )` ) ;
136+ }
137+ if ( ! checksumsResponse . ok ) {
138+ throw new Error ( `Failed to download ${ checksumsUrl } (${ checksumsResponse . status } )` ) ;
139+ }
140+
141+ const [ data , checksumsContent ] = await Promise . all ( [
142+ binaryResponse . arrayBuffer ( ) . then ( ( buf ) => new Uint8Array ( buf ) ) ,
143+ checksumsResponse . text ( ) ,
144+ ] ) ;
145+ const expectedHash = parseSha256SumFile ( checksumsContent , asset ) ;
146+ if ( ! expectedHash ) {
147+ throw new Error ( `SHA256SUMS missing entry for ${ asset } ` ) ;
148+ }
149+ const actualHash = sha256Hex ( data ) ;
150+ if ( actualHash !== expectedHash ) {
151+ throw new Error ( `Checksum mismatch for ${ asset } ` ) ;
95152 }
96153
97154 const tempDir = await mkdtemp ( join ( tmpdir ( ) , "ode-upgrade-" ) ) ;
98155 const tempPath = join ( tempDir , asset ) ;
99- const data = new Uint8Array ( await response . arrayBuffer ( ) ) ;
100156 await Bun . write ( tempPath , data ) ;
101157 if ( process . platform !== "win32" ) {
102158 await chmod ( tempPath , 0o755 ) ;
0 commit comments