Skip to content

Commit b4279b9

Browse files
committed
fix: Atomic download checks for binaries + codesign for macos (#252)
1 parent 727935e commit b4279b9

3 files changed

Lines changed: 157 additions & 16 deletions

File tree

.github/workflows/external-tests.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ jobs:
1414
lua-tests:
1515
name: e2e (${{ matrix.os }})
1616
runs-on: ${{ matrix.os }}
17+
# e2e tests could be flaky on CI so we do not block release creation if they failed
18+
continue-on-error: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
1719
strategy:
1820
fail-fast: false
1921
matrix:

.github/workflows/release.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ jobs:
8484
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-nvim
8585
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
8686
87+
- name: Ad-hoc sign macOS binary
88+
if: contains(matrix.os, 'macos')
89+
run: codesign --force --sign - "${{ matrix.target }}.${{ matrix.ext }}"
90+
8791
- name: Build for Windows
8892
if: contains(matrix.os, 'windows')
8993
shell: bash
@@ -190,6 +194,10 @@ jobs:
190194
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-c
191195
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
192196
197+
- name: Ad-hoc sign macOS binary
198+
if: contains(matrix.os, 'macos')
199+
run: codesign --force --sign - "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
200+
193201
- name: Build for Windows
194202
if: contains(matrix.os, 'windows')
195203
shell: bash

lua/fff/download.lua

Lines changed: 147 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,26 @@ end
2525
local 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
2948
end
3049

3150
local 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)
6590
end
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+
67131
local 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+
.. '.\nThe 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
100229
end
101230

102231
function 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

Comments
 (0)