2525local function binary_exists (plugin_dir )
2626 local binary_path = get_binary_path (plugin_dir )
2727 local stat = vim .uv .fs_stat (binary_path )
28- return stat and stat .type == ' file'
28+ if stat and stat .type == ' file' then return true end
29+
30+ -- On Windows the rename over a loaded DLL fails, so a verified binary may be
31+ -- left at binary_path .. '.tmp'. Promote it now that the old session is gone.
32+ local tmp_path = binary_path .. ' .tmp'
33+ local tmp_stat = vim .uv .fs_stat (tmp_path )
34+ if tmp_stat and tmp_stat .type == ' file' then
35+ -- Verify the .tmp is a valid library before promoting it, in case the
36+ -- process was killed between the loadlib check and the rename attempt
37+ -- during a previous download, leaving a corrupt or partial .tmp on disk.
38+ local loader = package.loadlib (tmp_path , ' luaopen_fff_nvim' )
39+ if not loader then
40+ vim .uv .fs_unlink (tmp_path )
41+ return false
42+ end
43+ local ok = vim .uv .fs_rename (tmp_path , binary_path )
44+ return ok ~= nil
45+ end
46+
47+ return false
2948end
3049
3150local function download_file (url , output_path , opts , callback )
@@ -53,6 +72,12 @@ local function download_file(url, output_path, opts, callback)
5372 table.insert (curl_args , opts .proxy )
5473 end
5574
75+ if opts .extra_curl_args then
76+ for _ , arg in ipairs (opts .extra_curl_args ) do
77+ table.insert (curl_args , arg )
78+ end
79+ end
80+
5681 table.insert (curl_args , url )
5782 vim .system (curl_args , {}, function (result )
5883 if result .code ~= 0 then
@@ -64,39 +89,143 @@ local function download_file(url, output_path, opts, callback)
6489 end )
6590end
6691
92+ --- Verify the SHA256 of a file against an expected hash string.
93+ --- @param file_path string
94+ --- @param expected_hash string lowercase hex SHA256
95+ --- @param callback fun ( ok : boolean , err : string | nil )
96+ local function verify_sha256 (file_path , expected_hash , callback )
97+ local cmd
98+ local sysname = vim .uv .os_uname ().sysname :lower ()
99+ if sysname :match (' windows' ) then
100+ cmd = { ' certutil' , ' -hashfile' , file_path , ' SHA256' }
101+ elseif sysname == ' darwin' then
102+ cmd = { ' shasum' , ' -a' , ' 256' , file_path }
103+ else
104+ cmd = { ' sha256sum' , file_path }
105+ end
106+
107+ vim .system (cmd , {}, function (result )
108+ if result .code ~= 0 then
109+ local detail = (result .stderr and result .stderr ~= ' ' and result .stderr )
110+ or (result .stdout and result .stdout ~= ' ' and result .stdout )
111+ or ' unknown error'
112+ callback (false , ' sha256 command failed: ' .. detail )
113+ return
114+ end
115+
116+ local actual_hash = (result .stdout or ' ' ):match (' ^%s*([0-9a-fA-F]+)' )
117+ if not actual_hash then
118+ callback (false , ' Could not parse sha256 output: ' .. tostring (result .stdout ))
119+ return
120+ end
121+
122+ if actual_hash :lower () ~= expected_hash :lower () then
123+ callback (false , string.format (' SHA256 mismatch: expected %s, got %s' , expected_hash , actual_hash :lower ()))
124+ return
125+ end
126+
127+ callback (true , nil )
128+ end )
129+ end
130+
67131local function download_from_github (version , binary_path , opts , callback )
68132 opts = opts or {}
69133
70134 local triple = system .get_triple ()
71135 local extension = system .get_lib_extension ()
72136 local binary_name = triple .. ' .' .. extension
73137 local url = string.format (' https://github.com/%s/releases/download/%s/%s' , GITHUB_REPO , version , binary_name )
138+ local sha_url = url .. ' .sha256'
139+
74140 vim .schedule (function ()
75141 vim .notify (string.format (' Downloading fff.nvim binary for ' .. version ), vim .log .levels .INFO )
76142 vim .notify (string.format (' Do not open fff until you see a success notification.' ), vim .log .levels .WARN )
77143 end )
78144
79- download_file (url , binary_path , {
80- proxy = opts .proxy ,
81- extra_curl_args = opts .extra_curl_args ,
82- }, function (success , err )
83- if not success then
84- callback (false , err )
145+ -- Download to a temp path first so we can verify before replacing the live binary.
146+ -- If we wrote directly to binary_path and the current process already has the old
147+ -- library loaded, package.loadlib() on the same path returns the *cached* handle —
148+ -- meaning a partial or corrupt download would pass verification silently.
149+ -- Using a distinct temp path forces dlopen to load the new file for real.
150+ local tmp_path = binary_path .. ' .tmp'
151+ local tmp_sha_path = tmp_path .. ' .sha256'
152+
153+ -- Download the SHA256 checksum file first so we can verify the binary.
154+ download_file (sha_url , tmp_sha_path , { proxy = opts .proxy }, function (sha_success , sha_err )
155+ if not sha_success then
156+ callback (false , ' Failed to download sha256: ' .. (sha_err or ' unknown error' ))
85157 return
86158 end
87159
88- -- Verify the binary can be loaded
89- local ok , err_msg = pcall (function () package.loadlib (binary_path , ' luaopen_fff_nvim' ) end )
160+ -- Read expected hash (first token on first line)
161+ local sha_file = io.open (tmp_sha_path , ' r' )
162+ local expected_hash = sha_file and sha_file :read (' *l' ):match (' ^%s*([0-9a-fA-F]+)' )
163+ if sha_file then sha_file :close () end
164+ vim .uv .fs_unlink (tmp_sha_path )
90165
91- if not ok then
92- vim .uv .fs_unlink (binary_path )
93- callback (false , ' Downloaded binary is not valid: ' .. (err_msg or ' unknown error' ))
166+ if not expected_hash or # expected_hash ~= 64 then
167+ callback (false , ' Invalid sha256 file contents' )
94168 return
95169 end
96170
97- vim .schedule (function () vim .notify (' fff.nvim binary downloaded successfully!' , vim .log .levels .INFO ) end )
98- callback (true , nil )
99- end )
171+ download_file (url , tmp_path , {
172+ proxy = opts .proxy ,
173+ extra_curl_args = opts .extra_curl_args ,
174+ }, function (success , err )
175+ if not success then
176+ vim .uv .fs_unlink (tmp_path )
177+ callback (false , err )
178+ return
179+ end
180+
181+ -- Verify integrity before doing anything else with the binary.
182+ verify_sha256 (tmp_path , expected_hash , function (hash_ok , hash_err )
183+ vim .schedule (function ()
184+ if not hash_ok then
185+ vim .uv .fs_unlink (tmp_path )
186+ callback (false , ' Binary integrity check failed: ' .. (hash_err or ' unknown error' ))
187+ return
188+ end
189+
190+ -- Verify the NEW binary (temp path is not yet loaded by this process,
191+ -- so dlopen actually loads and validates the downloaded file).
192+ -- Note: package.loadlib returns (nil, error_string) on failure rather than throwing,
193+ -- so we check the return value directly instead of using pcall.
194+ local loader , load_err = package.loadlib (tmp_path , ' luaopen_fff_nvim' )
195+
196+ if not loader then
197+ vim .uv .fs_unlink (tmp_path )
198+ callback (false , ' Downloaded binary is not valid: ' .. (load_err or ' unknown error' ))
199+ return
200+ end
201+
202+ -- Atomically replace the live binary only after successful verification.
203+ -- On Windows the old .dll may be locked by the current process, so rename can
204+ -- fail if fff is already loaded. In that case, leave the verified .tmp on disk
205+ -- so the next Neovim start can pick it up automatically.
206+ local rename_ok , rename_err = vim .uv .fs_rename (tmp_path , binary_path )
207+ if not rename_ok then
208+ if vim .uv .os_uname ().sysname :lower ():match (' windows' ) then
209+ vim .notify (
210+ ' fff.nvim binary downloaded to '
211+ .. tmp_path
212+ .. ' .\n The live binary is locked by the current session — please restart Neovim to apply the update.' ,
213+ vim .log .levels .WARN
214+ )
215+ callback (true , nil )
216+ else
217+ vim .uv .fs_unlink (tmp_path )
218+ callback (false , ' Failed to install binary: ' .. (rename_err or ' unknown error' ))
219+ end
220+ return
221+ end
222+
223+ vim .notify (' fff.nvim binary downloaded successfully!' , vim .log .levels .INFO )
224+ callback (true , nil )
225+ end )
226+ end ) -- verify_sha256
227+ end ) -- binary download_file
228+ end ) -- sha download_file
100229end
101230
102231function M .ensure_downloaded (opts , callback )
@@ -131,7 +260,9 @@ function M.download_binary(callback)
131260 if callback then
132261 callback (false , err )
133262 else
134- error (' Failed to download fff.nvim binary: ' .. (err or ' unknown error' ))
263+ vim .schedule (function ()
264+ vim .notify (' Failed to download fff.nvim binary: ' .. (err or ' unknown error' ), vim .log .levels .ERROR )
265+ end )
135266 end
136267 return
137268 end
0 commit comments