From 460dc2c5c80e7df1a1a16471757a7778f817cdcc Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 12 Sep 2026 00:08:18 +1000 Subject: [PATCH 1/3] Install plugins from a branch Many upstream plugins publish no tags at all, so a tag-only installer cannot manage them. - Add a branch spec field, mutually exclusive with tag and validated the same way - Build archive/refs/heads/ URLs for branch specs - Refetch a branch only on :ArtiPackUpdate or a forced sync, never from cache - Record the branch, archive digest and fetch time in the lockfile - Report a branch plugin as tracked and fetched, never as up to date --- README.md | 97 +++++++++++++++------ doc/arti-pack.txt | 87 ++++++++++++++----- lua/arti-pack/fs.lua | 17 ++++ lua/arti-pack/init.lua | 68 ++++++++++++--- lua/arti-pack/installer.lua | 36 ++++++-- lua/arti-pack/lock.lua | 27 +++++- lua/arti-pack/spec.lua | 32 ++++--- plugin/arti-pack.lua | 6 +- tests/fs_spec.lua | 21 +++++ tests/init_spec.lua | 164 ++++++++++++++++++++++++++++++++++++ tests/installer_spec.lua | 61 ++++++++++++++ tests/lock_spec.lua | 80 ++++++++++++++++++ tests/spec_spec.lua | 69 +++++++++++++++ tests/zipfile.lua | 76 +++++++++++++++++ 14 files changed, 765 insertions(+), 76 deletions(-) create mode 100644 tests/zipfile.lua diff --git a/README.md b/README.md index e76cc9a..29b9541 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # arti-pack A Neovim plugin manager that installs plugins from an artifact repository -instead of git. It downloads tagged upstream source archives over HTTP and -unpacks them into Neovim's native package path — see `:h packages`. +instead of git. It downloads upstream source archives over HTTP — a tag for +anything that publishes one, a branch for anything that does not — and unpacks +them into Neovim's native package path — see `:h packages`. -It works with any HTTP artifact repository that serves an upstream tag archive -at a stable URL — a JFrog Artifactory remote GitHub repository, or any caching -proxy with the same path shape. Nothing is cloned, no git binary is needed, and -the editor never has to reach GitHub: every plugin arrives as a cached, -immutable, versioned artifact from the same store your builds already pull -from. +It works with any HTTP artifact repository that serves upstream tag and branch +archives at a stable URL — a JFrog Artifactory remote GitHub repository, or any +caching proxy with the same path shape. Nothing is cloned, no git binary is +needed, and the editor never has to reach GitHub: every plugin arrives as a +cached artifact from the same store your builds already pull from. ## Requirements @@ -55,6 +55,7 @@ require('arti-pack').setup({ { 'folke/which-key.nvim', tag = 'v3.13.3', opt = true }, { 'folke/tokyonight.nvim', tag = 'v4.10.0', as = 'tokyonight' }, { 'tpope/vim-fugitive', tag = 'v3.7', ext = 'tar.gz' }, + { 'hrsh7th/cmp-path', branch = 'main' }, }, }) ``` @@ -72,20 +73,28 @@ whitespace is trimmed and anything else is rejected. Until you set it, `:ArtiPackClean` and `:ArtiPackList` never resolve a URL, so they work without it, as do plugins that carry an explicit `url`. -Every plugin needs an explicit `tag` — that is the point. Plugins install to +Every plugin names an explicit ref — either a `tag` or a `branch`, never both +and never neither. Plugins install to `stdpath('data')/site/pack/arti-pack/start/`, or `.../opt/` when `opt = true`. ### Spec fields -| Field | Default | Meaning | -| ------ | ---------- | ---------------------------------------------------- | -| `[1]` | *required* | `owner/repo` | -| `tag` | *required* | Upstream tag, used verbatim in the archive path | -| `opt` | `false` | Install under `opt/` for `:packadd` instead of `start/` | -| `as` | repo name | Directory name to install as | -| `ext` | `'zip'` | `zip` or `tar.gz` | -| `url` | — | Full archive URL, bypassing the registry (no whitespace) | +| Field | Default | Meaning | +| -------- | ---------- | ---------------------------------------------------- | +| `[1]` | *required* | `owner/repo` | +| `tag` | *one of* | Upstream tag, used verbatim in the archive path | +| `branch` | *one of* | Upstream branch to track, used verbatim in the archive path | +| `opt` | `false` | Install under `opt/` for `:packadd` instead of `start/` | +| `as` | repo name | Directory name to install as | +| `ext` | `'zip'` | `zip` or `tar.gz` | +| `url` | — | Full archive URL, bypassing the registry (no whitespace) | + +`tag` and `branch` are mutually exclusive: a spec setting both, or neither, is +rejected with an error naming the plugin and nothing is installed for it. Both +are one path segment of the archive URL, so both are trimmed of surrounding +whitespace and rejected if they contain inner whitespace, `/`, `\`, or are `.` +or `..`. A branch whose name contains `/` therefore needs an explicit `url`. ### Options @@ -98,32 +107,66 @@ Every plugin needs an explicit `tag` — that is the point. Plugins install to ### Commands -| Command | Effect | -| ---------------- | ---------------------------------------------------------- | -| `:ArtiPackSync` | Install missing plugins and update ones whose tag changed | -| `:ArtiPackSync!` | Reinstall everything, ignoring recorded state | -| `:ArtiPackClean` | Remove installed plugins no longer in the spec | -| `:ArtiPackList` | Show every configured plugin and its state | +| Command | Effect | +| ----------------- | ---------------------------------------------------------- | +| `:ArtiPackSync` | Install missing plugins and apply changed refs | +| `:ArtiPackSync!` | Reinstall everything, ignoring recorded state | +| `:ArtiPackUpdate` | Refetch every branch-tracking plugin | +| `:ArtiPackClean` | Remove installed plugins no longer in the spec | +| `:ArtiPackList` | Show every configured plugin and its state | + +## Tracking a branch + +Plenty of upstreams publish no tags at all, so a `branch` spec installs from the +branch archive instead. A branch is mutable, and that changes when arti-pack +fetches: + +- `:ArtiPackSync` installs a branch plugin that is missing, that moved between + `start` and `opt`, or whose `branch` you changed — and otherwise leaves it + alone. It says how many branch plugins it did not recheck. Startup cost and + the load on your artifact store stay the same as a tag-only setup, and your + editor does not change under you on a restart. +- `:ArtiPackUpdate` refetches every branch plugin, whether or not upstream + moved. This is the only command that goes looking for new branch content. +- `:ArtiPackSync!` still reinstalls everything. + +Branch archives are never served from the local archive cache, so an update +always sees what the store has now. + +The lockfile records the branch name, a digest of the archive that was actually +installed, and when it was fetched — never a version, because a branch is not +one. `:ArtiPackList` reports that instead of guessing: + +``` +hrsh7th/cmp-path @main start tracking branch main, fetched 2026-09-08 10:14 (7db8f64a) +nvim-lua/plenary.nvim v0.1.4 start installed +``` + +A branch plugin is never reported as up to date: nothing on disk can know +whether upstream has moved. After an update, an unchanged digest is reported as +`unchanged` rather than as an update that changed nothing. ## How it resolves an archive ``` ///archive/refs/tags/. +///archive/refs/heads/. ``` -which for the example above is +which for the examples above are ``` https://artifacts.example.com/artifactory/github-remote/nvim-lua/plenary.nvim/archive/refs/tags/v0.1.4.zip +https://artifacts.example.com/artifactory/github-remote/hrsh7th/cmp-path/archive/refs/heads/main.zip ``` -That is GitHub's own tag-archive path shape, so a remote repository proxying +That is GitHub's own archive path shape, so a remote repository proxying `github.com` resolves it unchanged. Archives are cached under `stdpath('cache')/arti-pack`, checked for a real zip or gzip magic header before being trusted, and unpacked into a staging -directory. Tag archives wrap their contents in a single `-` -directory, which is stripped on the way in. Installed tags are recorded in +directory. Source archives wrap their contents in a single `-` +directory, which is stripped on the way in. Installs are recorded in `stdpath('data')/arti-pack/lock.json`, so a sync with nothing to do costs nothing. Helptags are regenerated per plugin after install. diff --git a/doc/arti-pack.txt b/doc/arti-pack.txt index 601bd73..2cf50fd 100644 --- a/doc/arti-pack.txt +++ b/doc/arti-pack.txt @@ -6,19 +6,21 @@ CONTENTS *arti-pack-contents* 1. Introduction ......................... |arti-pack-introduction| 2. Setup ................................ |arti-pack-setup| 3. Plugin specs ......................... |arti-pack-specs| - 4. Commands ............................. |arti-pack-commands| - 5. Layout ............................... |arti-pack-layout| + 4. Branch tracking ...................... |arti-pack-branches| + 5. Commands ............................. |arti-pack-commands| + 6. Layout ............................... |arti-pack-layout| ============================================================================== 1. INTRODUCTION *arti-pack-introduction* -arti-pack installs plugins from tagged upstream source archives served over -HTTP by an artifact repository, and unpacks them into Neovim's native package -path. See |packages| for how Neovim loads what arti-pack writes. +arti-pack installs plugins from upstream source archives served over HTTP by an +artifact repository — a tag for anything that publishes one, a branch for +anything that does not — and unpacks them into Neovim's native package path. +See |packages| for how Neovim loads what arti-pack writes. -Any artifact repository that serves an upstream tag archive at a stable URL -works — a JFrog Artifactory remote GitHub repository, or any caching proxy -with the same path shape. Nothing is cloned and the editor never reaches +Any artifact repository that serves upstream tag and branch archives at a +stable URL works — a JFrog Artifactory remote GitHub repository, or any caching +proxy with the same path shape. Nothing is cloned and the editor never reaches GitHub itself. Requires curl. Zip and tar.gz archives are unpacked in pure Lua, so no @@ -35,6 +37,7 @@ entry's CRC32 is checked before it lands on disk. plugins = { { 'nvim-lua/plenary.nvim', tag = 'v0.1.4' }, { 'folke/which-key.nvim', tag = 'v3.13.3', opt = true }, + { 'hrsh7th/cmp-path', branch = 'main' }, }, }) < @@ -59,23 +62,67 @@ Options: Each entry is a table whose first element is "owner/repo". - tag Required. Upstream tag, used verbatim in the archive path. - opt Install under opt/ for |:packadd| instead of start/. Default false. - as Directory name to install as. Defaults to the repo name. - ext "zip" or "tar.gz". Default "zip". - url Full archive URL, bypassing the registry. Trimmed; it may not - contain whitespace. + tag Upstream tag, used verbatim in the archive path. + branch Upstream branch to track, used verbatim in the archive path. + See |arti-pack-branches|. + opt Install under opt/ for |:packadd| instead of start/. Default false. + as Directory name to install as. Defaults to the repo name. + ext "zip" or "tar.gz". Default "zip". + url Full archive URL, bypassing the registry. Trimmed; it may not + contain whitespace. + +Exactly one of "tag" and "branch" is required. A spec that sets both, or +neither, is rejected with an error naming the plugin, and nothing is installed +for it. Both are one path segment of the archive URL, so both are trimmed of +surrounding whitespace and rejected if they contain inner whitespace, "/" or +"\", or are "." or "..". A branch name containing "/" therefore needs an +explicit "url". The archive URL is built as: > ///archive/refs/tags/. + ///archive/refs/heads/. < ============================================================================== -4. COMMANDS *arti-pack-commands* +4. BRANCH TRACKING *arti-pack-branches* + +Plenty of upstreams publish no tags at all, so a "branch" spec installs from +the branch archive instead. A tag is immutable; a branch is not, so a branch +plugin is refetched only when asked for: + + |:ArtiPackSync| installs a branch plugin that is missing, that moved + between start/ and opt/, or whose "branch" changed, and + otherwise leaves it alone. It reports how many branch + plugins it did not recheck. Startup cost and load on the + artifact store stay as they are for tags, and the editor + does not change under you on a restart. + |:ArtiPackUpdate| refetches every branch plugin, whether or not upstream + moved. Nothing else goes looking for new branch content. + |:ArtiPackSync|! still reinstalls everything. + +Branch archives are never served from the local archive cache, so an update +always sees what the store has now. + +The lockfile records the branch name, a digest of the archive that was actually +installed, and when it was fetched — never a version, since a branch is not +one. |:ArtiPackList| reports that rather than guessing: > + hrsh7th/cmp-path @main start tracking branch main, fetched + 2026-09-08 10:14 (7db8f64a) +< +A branch plugin is never reported as up to date, because nothing on disk can +know whether upstream has moved. An update that brings back the same digest is +reported as unchanged, not as an update. + +============================================================================== +5. COMMANDS *arti-pack-commands* *:ArtiPackSync* -:ArtiPackSync Install missing plugins and update ones whose tag changed. +:ArtiPackSync Install missing plugins and apply changed refs. :ArtiPackSync! Reinstall every configured plugin, ignoring recorded state. + *:ArtiPackUpdate* +:ArtiPackUpdate Refetch every branch-tracking plugin. See + |arti-pack-branches|. + *:ArtiPackClean* :ArtiPackClean Remove installed plugins that are no longer in the spec. @@ -83,16 +130,16 @@ The archive URL is built as: > :ArtiPackList Show every configured plugin and its state. ============================================================================== -5. LAYOUT *arti-pack-layout* +6. LAYOUT *arti-pack-layout* stdpath('data')/site/pack/arti-pack/start/ loaded at startup stdpath('data')/site/pack/arti-pack/opt/ loaded by |:packadd| - stdpath('data')/arti-pack/lock.json recorded tags + stdpath('data')/arti-pack/lock.json recorded installs stdpath('cache')/arti-pack downloaded archives Downloads are checked for a zip or gzip magic header before being trusted, and -the single "-" directory that tag archives wrap their contents -in is stripped on the way in. A plugin that fails is reported and skipped; the +the single "-" directory that source archives wrap their contents in +is stripped on the way in. A plugin that fails is reported and skipped; the rest of the run continues. vim:tw=78:ts=8:noet:ft=help:norl: diff --git a/lua/arti-pack/fs.lua b/lua/arti-pack/fs.lua index ef94cc6..94e5571 100644 --- a/lua/arti-pack/fs.lua +++ b/lua/arti-pack/fs.lua @@ -60,6 +60,23 @@ function M.read_head(path, bytes) return head end +-- Identifies what was actually fetched. A branch name says nothing about +-- content, so the digest is what makes a branch install reportable. Archive +-- bytes reach sha256() as a Blob, which it refuses, so they are hashed in their +-- base64 form: an identity for the content, not the file's sha256sum. +function M.digest(path) + local fd = io.open(path, 'rb') + if not fd then + return nil + end + local data = fd:read('*a') + fd:close() + if not data then + return nil + end + return vim.fn.sha256(vim.base64.encode(data)) +end + function M.verify_archive(path, ext) local stat = uv.fs_stat(path) if not stat or stat.size == 0 then diff --git a/lua/arti-pack/init.lua b/lua/arti-pack/init.lua index 7c75cf6..2c0e336 100644 --- a/lua/arti-pack/init.lua +++ b/lua/arti-pack/init.lua @@ -59,7 +59,7 @@ local function installed_dir(ctx, name, opt) end local function finalise(ctx, results) - local counts = { installed = 0, updated = 0, failed = 0 } + local counts = { installed = 0, updated = 0, unchanged = 0, failed = 0 } for _, res in ipairs(results) do counts[res.status] = (counts[res.status] or 0) + 1 @@ -85,7 +85,7 @@ local function finalise(ctx, results) end local parts = {} - for _, key in ipairs({ 'installed', 'updated', 'failed' }) do + for _, key in ipairs({ 'installed', 'updated', 'unchanged', 'failed' }) do if counts[key] > 0 then table.insert(parts, string.format('%d %s', counts[key], key)) end @@ -98,6 +98,25 @@ local function finalise(ctx, results) end end +-- A branch install is only as fresh as the last fetch, and a plain sync does not +-- fetch it again, so an idle run says what it did not check instead of claiming +-- everything is current. +local function idle_message(specs) + local branches = 0 + for _, spec in ipairs(specs) do + if spec.branch then + branches = branches + 1 + end + end + if branches == 0 then + return 'everything up to date' + end + return string.format( + 'nothing to install; %d branch plugin(s) not rechecked, :ArtiPackUpdate refetches them', + branches + ) +end + function M.sync(opts) opts = opts or {} if running then @@ -112,14 +131,18 @@ function M.sync(opts) local ctx = context() local pending = {} for _, spec in ipairs(specs) do - local current = ctx.lock:is_current(spec) and fs.exists(installed_dir(ctx, spec.name, spec.opt)) - if opts.force or not current then + local wanted = ctx.lock:needs_install(spec, { + force = opts.force, + update = opts.update, + present = fs.exists(installed_dir(ctx, spec.name, spec.opt)), + }) + if wanted then table.insert(pending, spec) end end if #pending == 0 then - return notify('everything up to date') + return notify(opts.update and 'no branch-tracking plugins to update' or idle_message(specs)) end local reg_err = registry_error(pending, ctx.registry) @@ -135,6 +158,11 @@ function M.sync(opts) end) end +-- Branch archives are mutable, so nothing refetches them implicitly. +function M.update() + return M.sync({ update = true }) +end + function M.clean() local specs, errors = spec_mod.parse_all(M.config.plugins) for _, err in ipairs(errors) do @@ -179,6 +207,24 @@ function M.clean() end end +local function locked_ref(entry) + if entry.branch then + return 'branch ' .. entry.branch + end + return tostring(entry.tag) +end + +local function fetched(entry) + if type(entry.installed_at) ~= 'number' then + return 'at an unknown time' + end + local stamp = os.date('%Y-%m-%d %H:%M', entry.installed_at) + if type(entry.digest) == 'string' then + return string.format('%s (%s)', stamp, entry.digest:sub(1, 8)) + end + return stamp +end + function M.list() local specs, errors = spec_mod.parse_all(M.config.plugins) local ctx = context() @@ -190,16 +236,18 @@ function M.list() if not entry then state = 'missing' elseif not ctx.lock:is_current(spec) then - state = 'outdated (' .. tostring(entry.tag) .. ')' + state = 'outdated (' .. locked_ref(entry) .. ')' elseif not fs.exists(installed_dir(ctx, spec.name, spec.opt)) then state = 'gone from disk' + elseif spec.branch then + -- A branch may have moved upstream since this was fetched and nothing here + -- can know, so report what was installed and when, never "installed". + state = string.format('tracking branch %s, fetched %s', spec.branch, fetched(entry)) else state = 'installed' end - table.insert( - lines, - string.format('%-32s %-14s %-5s %s', spec.source, spec.tag, spec.opt and 'opt' or 'start', state) - ) + local ref = spec.branch and ('@' .. spec.branch) or spec.tag + table.insert(lines, string.format('%-32s %-14s %-5s %s', spec.source, ref, spec.opt and 'opt' or 'start', state)) end for _, name in ipairs(ctx.lock:names()) do diff --git a/lua/arti-pack/installer.lua b/lua/arti-pack/installer.lua index c9a285c..f893227 100644 --- a/lua/arti-pack/installer.lua +++ b/lua/arti-pack/installer.lua @@ -4,7 +4,14 @@ local spec_mod = require('arti-pack.spec') local M = {} local function result(spec, status, err) - return { name = spec.name, source = spec.source, tag = spec.tag, status = status, err = err } + return { + name = spec.name, + source = spec.source, + tag = spec.tag, + branch = spec.branch, + status = status, + err = err, + } end local function target_dir(ctx, spec) @@ -29,6 +36,7 @@ end local function place(ctx, spec, stage, done) local root = fs.archive_root(stage) local target = target_dir(ctx, spec) + local had_target = fs.exists(target) fs.mkdirp(vim.fn.fnamemodify(target, ':h')) fs.rm_rf(target) @@ -43,6 +51,8 @@ local function place(ctx, spec, stage, done) local previous = ctx.lock:get(spec.name) ctx.lock:set(spec.name, { tag = spec.tag, + branch = spec.branch, + digest = spec.digest, opt = spec.opt, source = spec.source, ext = spec.ext, @@ -50,10 +60,22 @@ local function place(ctx, spec, stage, done) installed_at = os.time(), }) - done(result(spec, previous and 'updated' or 'installed')) + -- Refetching a branch usually lands the same bytes; saying so is more useful + -- than reporting an update that changed nothing. + local status = 'installed' + if previous then + local same = had_target and spec.digest ~= nil and previous.digest == spec.digest + status = same and 'unchanged' or 'updated' + end + + done(result(spec, status)) end local function extract(ctx, spec, archive, done) + if spec.branch then + spec.digest = fs.digest(archive) + end + local stage = fs.join(ctx.pack_dir, '.stage', spec.name) fs.rm_rf(stage) fs.mkdirp(stage) @@ -75,7 +97,9 @@ local function download(ctx, spec, archive, done) end spec.resolved_url = url - if fs.exists(archive) and fs.verify_archive(archive, spec.ext) then + -- Only a tag archive is safe to serve from cache: the bytes behind a branch + -- archive change, so a cached one would make every update a no-op. + if not spec.branch and fs.exists(archive) and fs.verify_archive(archive, spec.ext) then return extract(ctx, spec, archive, done) end @@ -120,14 +144,16 @@ local function download(ctx, spec, archive, done) end function M.install(ctx, spec, done) - local archive = fs.join(ctx.cache_dir, string.format('%s-%s.%s', spec.name, spec.tag, spec.ext)) + local slot = spec.branch and string.format('%s-heads-%s', spec.name, spec.branch) + or string.format('%s-%s', spec.name, spec.tag) + local archive = fs.join(ctx.cache_dir, string.format('%s.%s', slot, spec.ext)) local ok, err = pcall(download, ctx, spec, archive, done) if not ok then done(result(spec, 'failed', tostring(err))) end end --- Failures are per plugin: one bad tag must not abandon the rest of the run. +-- Failures are per plugin: one bad ref must not abandon the rest of the run. function M.run(ctx, specs, on_done) local results, started, finished = {}, 0, 0 local limit = math.max(1, ctx.concurrency or 4) diff --git a/lua/arti-pack/lock.lua b/lua/arti-pack/lock.lua index ba01e7a..76b8f76 100644 --- a/lua/arti-pack/lock.lua +++ b/lua/arti-pack/lock.lua @@ -74,14 +74,37 @@ function M:names() return names end --- A plugin is current when the recorded tag and load style both still match the +-- A plugin is current when the recorded ref and load style both still match the -- spec; a flipped opt flag has to move the directory, so it counts as changed. +-- For a branch this answers "are we tracking the ref you asked for", not "is the +-- installed content still what that branch points at": a branch moves upstream +-- and nothing on disk can tell us that it has. function M:is_current(spec) local entry = self.entries[spec.name] if not entry then return false end - return entry.tag == spec.tag and (entry.opt == true) == (spec.opt == true) + if (entry.opt == true) ~= (spec.opt == true) then + return false + end + if spec.branch then + return entry.branch == spec.branch + end + return entry.tag == spec.tag and entry.branch == nil +end + +-- The whole update policy, in one place. A tag is immutable, so a matching entry +-- with its directory still on disk needs no work. A branch is mutable, so it is +-- refetched only when asked for: a forced sync, or an update run. +function M:needs_install(spec, opts) + opts = opts or {} + if opts.force then + return true + end + if not (self:is_current(spec) and opts.present) then + return true + end + return opts.update == true and spec.branch ~= nil end return M diff --git a/lua/arti-pack/spec.lua b/lua/arti-pack/spec.lua index 2bf6f6e..69af109 100644 --- a/lua/arti-pack/spec.lua +++ b/lua/arti-pack/spec.lua @@ -32,13 +32,20 @@ function M.parse(entry) return nil, string.format('invalid source %q, expected "owner/repo"', source) end - local tag = entry.tag - if type(tag) ~= 'string' or trim(tag) == '' then - return nil, string.format('%s is missing a tag', source) + if entry.tag ~= nil and entry.branch ~= nil then + return nil, string.format('%s sets both a tag and a branch, pick one', source) end - tag = trim(tag) - if tag:find('[/\\%s]') then - return nil, string.format('%s has an invalid tag %q', source, tag) + + -- A ref is one path segment of the archive URL, so the same rules that keep a + -- directory name from escaping its parent apply to it. + local kind = entry.branch ~= nil and 'branch' or 'tag' + local ref = entry.branch ~= nil and entry.branch or entry.tag + if type(ref) ~= 'string' or trim(ref) == '' then + return nil, string.format('%s is missing a tag or a branch', source) + end + ref = trim(ref) + if bad_name(ref) then + return nil, string.format('%s has an invalid %s %q', source, kind, ref) end local ext = entry.ext or 'zip' @@ -67,7 +74,9 @@ function M.parse(entry) owner = owner, repo = repo, name = name, - tag = tag, + tag = kind == 'tag' and ref or nil, + branch = kind == 'branch' and ref or nil, + ref = ref, ext = ext, opt = entry.opt == true, url = url, @@ -93,8 +102,9 @@ function M.check_registry(registry) return (base:gsub('/+$', '')) end --- Tagged source archives live at the same path shape on every GitHub-alike, so --- the registry is just the proxy prefix in front of "/". +-- Tag and branch source archives live at the same path shape on every +-- GitHub-alike, so the registry is just the proxy prefix in front of +-- "/". function M.url(spec, registry) if spec.url then return spec.url @@ -109,8 +119,8 @@ function M.url(spec, registry) spec.repo, 'archive', 'refs', - 'tags', - spec.tag .. '.' .. spec.ext, + spec.branch and 'heads' or 'tags', + spec.ref .. '.' .. spec.ext, }, '/') end diff --git a/plugin/arti-pack.lua b/plugin/arti-pack.lua index d7dd5de..a4b851f 100644 --- a/plugin/arti-pack.lua +++ b/plugin/arti-pack.lua @@ -9,7 +9,11 @@ end vim.api.nvim_create_user_command('ArtiPackSync', function(cmd) arti().sync({ force = cmd.bang }) -end, { bang = true, desc = 'Install missing arti-pack plugins and update changed tags' }) +end, { bang = true, desc = 'Install missing arti-pack plugins and apply changed refs' }) + +vim.api.nvim_create_user_command('ArtiPackUpdate', function() + arti().update() +end, { desc = 'Refetch every branch-tracking plugin' }) vim.api.nvim_create_user_command('ArtiPackClean', function() arti().clean() diff --git a/tests/fs_spec.lua b/tests/fs_spec.lua index affb8cc..300b713 100644 --- a/tests/fs_spec.lua +++ b/tests/fs_spec.lua @@ -74,6 +74,27 @@ t.describe('fs.verify_archive', function() end) end) +t.describe('fs.digest', function() + t.it('identifies file contents, not the file name', function() + local dir = tmpdir() + local a = write(dir .. '/a.zip', 'PK\3\4payload') + local b = write(dir .. '/b.zip', 'PK\3\4payload') + t.eq(fs.digest(a), fs.digest(b)) + t.eq(64, #fs.digest(a), 'a sha256 hex digest') + end) + + t.it('changes when the bytes change', function() + local dir = tmpdir() + local before = fs.digest(write(dir .. '/a.zip', 'PK\3\4one')) + local after = fs.digest(write(dir .. '/a.zip', 'PK\3\4two')) + t.truthy(before ~= after, 'a refetched archive with new contents digests differently') + end) + + t.it('returns nothing for a missing file', function() + t.eq(nil, fs.digest(tmpdir() .. '/absent.zip')) + end) +end) + t.describe('fs.extract', function() t.it('reports a missing archive instead of raising', function() local ok, err = fs.extract(tmpdir() .. '/absent.zip', tmpdir(), 'zip') diff --git a/tests/init_spec.lua b/tests/init_spec.lua index 4801154..d6b91ce 100644 --- a/tests/init_spec.lua +++ b/tests/init_spec.lua @@ -1,6 +1,7 @@ local t = require('tests.harness') local fs = require('arti-pack.fs') local Lock = require('arti-pack.lock') +local zipfile = require('tests.zipfile') local arti = require('arti-pack') local function tmpdir() @@ -87,11 +88,21 @@ local function install_fake(name, sub, tag) record(name, tag, sub == 'opt') end +local function install_branch_fake(name, branch, at) + place_dir(name, 'start') + local l = lock() + l:set(name, { branch = branch, opt = false, digest = string.rep('b', 64), installed_at = at }) + assert(l:save()) +end + -- sync finishes asynchronously; its closing notification is the only signal. local function is_terminal(msg) return msg:find('everything up to date', 1, true) ~= nil + or msg:find('nothing to install', 1, true) ~= nil + or msg:find('no branch-tracking plugins', 1, true) ~= nil or msg:find('%d+ installed') ~= nil or msg:find('%d+ updated') ~= nil + or msg:find('%d+ unchanged') ~= nil or msg:find('%d+ failed') ~= nil end @@ -115,6 +126,12 @@ local function sync(opts) drain(before) end +local function update() + local before = #env.notices + arti.update() + drain(before) +end + local function build_archive(ext, repo, version) local work = tmpdir() local root = string.format('%s-%s', repo, version) @@ -353,6 +370,89 @@ t.describe('arti-pack.sync', function() end) end) +-- Branch fixtures are built in Lua, so these run with no zip or tar around. +t.describe('arti-pack branch tracking', function() + local function branch_entry(source, branch, archive) + return { source, branch = branch, url = 'file://' .. archive } + end + + local function setup_branch(archive) + arti.setup({ auto_sync = false, plugins = { branch_entry('hrsh7th/cmp-path', 'main', archive) } }) + end + + t.it('installs a branch plugin and records the branch it tracks', function() + use_env() + local archive = zipfile.write(tmpdir() .. '/cmp-path-main.zip', 'cmp-path-main', 'first') + setup_branch(archive) + sync() + + t.truthy(fs.exists(env.pack_dir .. '/start/cmp-path/lua/marker.lua')) + t.eq('main', lock():get('cmp-path').branch) + t.eq(nil, lock():get('cmp-path').tag) + end) + + t.it('does not refetch a branch on an ordinary sync, and says so', function() + use_env() + local path = tmpdir() .. '/cmp-path-main.zip' + setup_branch(zipfile.write(path, 'cmp-path-main', 'first')) + sync() + + zipfile.write(path, 'cmp-path-main', 'second') + env.notices = {} + sync() + + t.matches('nothing to install', last_notice()) + t.matches('1 branch plugin%(s%) not rechecked', last_notice()) + t.matches('first', table.concat(vim.fn.readfile(env.pack_dir .. '/start/cmp-path/lua/marker.lua'), '\n')) + end) + + t.it('refetches a moved branch on update', function() + use_env() + local path = tmpdir() .. '/cmp-path-main.zip' + setup_branch(zipfile.write(path, 'cmp-path-main', 'first')) + sync() + + zipfile.write(path, 'cmp-path-main', 'second') + env.notices = {} + update() + + t.matches('1 updated', last_notice()) + t.matches('second', table.concat(vim.fn.readfile(env.pack_dir .. '/start/cmp-path/lua/marker.lua'), '\n')) + end) + + t.it('reports an update that changed nothing as unchanged', function() + use_env() + local archive = zipfile.write(tmpdir() .. '/cmp-path-main.zip', 'cmp-path-main', 'first') + setup_branch(archive) + sync() + + env.notices = {} + update() + t.matches('1 unchanged', last_notice()) + end) + + t.it('has nothing to update when every plugin is pinned to a tag', function() + use_env() + arti.setup({ auto_sync = false, plugins = { { 'owner/here.nvim', tag = 'v1' } } }) + install_fake('here.nvim', 'start', 'v1') + + env.notices = {} + update() + t.matches('no branch%-tracking plugins to update', last_notice()) + end) + + t.it('installs a branch plugin whose directory disappeared', function() + use_env() + local archive = zipfile.write(tmpdir() .. '/cmp-path-main.zip', 'cmp-path-main', 'first') + setup_branch(archive) + sync() + fs.rm_rf(env.pack_dir .. '/start/cmp-path') + + sync() + t.truthy(fs.exists(env.pack_dir .. '/start/cmp-path/lua/marker.lua')) + end) +end) + t.describe('arti-pack.clean', function() t.it('removes the directories and lock entries that are no longer configured', function() use_env() @@ -419,6 +519,39 @@ t.describe('arti-pack.clean', function() t.matches('missing a tag', notices(), 'the parse error is reported') t.matches('clean aborted', last_notice()) end) + + t.it('keeps a plugin that is configured to track a branch', function() + use_env() + arti.setup({ auto_sync = false, plugins = { { 'hrsh7th/cmp-path', branch = 'main' } } }) + install_branch_fake('cmp-path', 'main', 1757000000) + install_fake('drop.nvim', 'start') + + arti.clean() + + t.truthy(fs.exists(env.pack_dir .. '/start/cmp-path/marker.lua'), 'a branch plugin is still configured') + t.falsy(fs.exists(env.pack_dir .. '/start/drop.nvim')) + t.eq({ 'cmp-path' }, lock():names()) + end) + + t.it('deletes nothing when a branch spec is in error', function() + use_env() + arti.setup({ + auto_sync = false, + plugins = { + { 'hrsh7th/cmp-path', branch = 'main' }, + { 'owner/broken.nvim', tag = 'v1', branch = 'main' }, + }, + }) + install_branch_fake('cmp-path', 'main', 1757000000) + install_fake('orphan.nvim', 'start') + + arti.clean() + + t.truthy(fs.exists(env.pack_dir .. '/start/cmp-path')) + t.truthy(fs.exists(env.pack_dir .. '/start/orphan.nvim'), 'nothing is deleted from a partial spec set') + t.matches('sets both a tag and a branch', notices()) + t.matches('clean aborted', last_notice()) + end) end) t.describe('arti-pack.list', function() @@ -463,6 +596,37 @@ t.describe('arti-pack.list', function() t.eq(5, #lines) end) + t.it('never claims a branch plugin is up to date', function() + use_env() + arti.setup({ auto_sync = false, plugins = { { 'hrsh7th/cmp-path', branch = 'main' } } }) + install_branch_fake('cmp-path', 'main', 1757000000) + + local line = arti.list()[1] + + t.matches('@main', line, 'the branch is shown in place of a version') + t.matches('tracking branch main, fetched 20', line) + t.matches('%(bbbbbbbb%)$', line, 'the fetched archive digest is shown') + t.falsy(line:find('installed', 1, true), 'a branch install is never reported as simply installed') + end) + + t.it('reports a branch plugin whose lock entry tracks another branch', function() + use_env() + arti.setup({ auto_sync = false, plugins = { { 'hrsh7th/cmp-path', branch = 'master' } } }) + install_branch_fake('cmp-path', 'main', 1757000000) + + t.matches('outdated %(branch main%)$', arti.list()[1]) + end) + + t.it('reports a branch plugin that is missing from disk', function() + use_env() + arti.setup({ auto_sync = false, plugins = { { 'hrsh7th/cmp-path', branch = 'main' } } }) + local l = lock() + l:set('cmp-path', { branch = 'main', opt = false, installed_at = 1757000000 }) + assert(l:save()) + + t.matches('gone from disk$', arti.list()[1]) + end) + t.it('lists spec errors alongside the plugins', function() use_env() arti.setup({ auto_sync = false, plugins = { { 'owner/ok.nvim', tag = 'v1' }, 'bogus' } }) diff --git a/tests/installer_spec.lua b/tests/installer_spec.lua index 4582207..e60db8a 100644 --- a/tests/installer_spec.lua +++ b/tests/installer_spec.lua @@ -3,6 +3,7 @@ local fs = require('arti-pack.fs') local installer = require('arti-pack.installer') local Lock = require('arti-pack.lock') local spec_mod = require('arti-pack.spec') +local zipfile = require('tests.zipfile') local function tmpdir() local dir = vim.fn.tempname() @@ -74,6 +75,66 @@ local function spec_for(source, tag, archive, extra) return assert(spec_mod.parse(entry)) end +-- Branch fixtures are built in Lua, so these run with no zip or tar around. +t.describe('installer.run with a branch spec', function() + local function branch_spec(source, branch, archive) + return assert(spec_mod.parse({ source, branch = branch, url = 'file://' .. archive })) + end + + t.it('installs a branch archive and records the branch, not a tag', function() + local ctx = new_ctx() + local archive = zipfile.write(tmpdir() .. '/cmp-path-main.zip', 'cmp-path-main', 'first') + local results = run(ctx, { branch_spec('hrsh7th/cmp-path', 'main', archive) }) + + t.eq('installed', results[1].status, results[1].err) + t.truthy(fs.exists(ctx.pack_dir .. '/start/cmp-path/lua/marker.lua'), 'wrapper directory is stripped') + + local entry = ctx.lock:get('cmp-path') + t.eq('main', entry.branch) + t.eq(nil, entry.tag, 'a branch is not recorded as a version') + t.eq(64, #entry.digest, 'the fetched archive is identified by digest') + t.truthy(type(entry.installed_at) == 'number', 'the fetch time is recorded') + end) + + t.it('reports a refetch that brought back the same bytes as unchanged', function() + local ctx = new_ctx() + local archive = zipfile.write(tmpdir() .. '/cmp-path-main.zip', 'cmp-path-main', 'first') + run(ctx, { branch_spec('hrsh7th/cmp-path', 'main', archive) }) + local before = ctx.lock:get('cmp-path').digest + + local results = run(ctx, { branch_spec('hrsh7th/cmp-path', 'main', archive) }) + t.eq('unchanged', results[1].status, results[1].err) + t.eq(before, ctx.lock:get('cmp-path').digest) + end) + + t.it('reports a moved branch as an update and replaces the tree', function() + local ctx = new_ctx() + local path = tmpdir() .. '/cmp-path-main.zip' + run(ctx, { branch_spec('hrsh7th/cmp-path', 'main', zipfile.write(path, 'cmp-path-main', 'first')) }) + local before = ctx.lock:get('cmp-path').digest + write(ctx.pack_dir .. '/start/cmp-path/leftover.txt', 'stale') + + zipfile.write(path, 'cmp-path-main', 'second') + local results = run(ctx, { branch_spec('hrsh7th/cmp-path', 'main', path) }) + t.eq('updated', results[1].status, results[1].err) + t.falsy(fs.exists(ctx.pack_dir .. '/start/cmp-path/leftover.txt'), 'stale files are cleared') + t.truthy(before ~= ctx.lock:get('cmp-path').digest, 'the recorded digest follows the branch') + end) + + t.it('never serves a branch from the archive cache', function() + local ctx = new_ctx() + local archive = zipfile.write(tmpdir() .. '/cmp-path-main.zip', 'cmp-path-main', 'first') + run(ctx, { branch_spec('hrsh7th/cmp-path', 'main', archive) }) + + -- With the source gone a cached archive would silently succeed, which is + -- exactly what would make an update a no-op. + vim.fn.delete(archive) + local results = run(ctx, { branch_spec('hrsh7th/cmp-path', 'main', archive) }) + t.eq('failed', results[1].status) + t.matches('download failed', results[1].err) + end) +end) + t.describe('installer.run', function() if not have('zip') or not have('tar') then t.it('SKIPPED: zip/tar unavailable to build fixtures', function() end) diff --git a/tests/lock_spec.lua b/tests/lock_spec.lua index 4b6030b..4b7a31b 100644 --- a/tests/lock_spec.lua +++ b/tests/lock_spec.lua @@ -25,6 +25,23 @@ t.describe('lock', function() t.eq({ tag = 'v0.1.4', opt = false }, reloaded:get('plenary.nvim')) end) + t.it('round-trips a branch entry with what identifies the fetched content', function() + local path = tmpdir() .. '/lock.json' + local entry = { + branch = 'main', + digest = string.rep('a', 64), + installed_at = 1757000000, + opt = false, + source = 'hrsh7th/cmp-path', + ext = 'zip', + } + local lock = Lock.new(path) + lock:set('cmp-path', entry) + t.truthy(lock:save()) + + t.eq(entry, Lock.new(path):load():get('cmp-path')) + end) + t.it('starts empty when the state file does not exist', function() local lock = Lock.new(tmpdir() .. '/absent.json'):load() t.eq({}, lock:names()) @@ -101,4 +118,67 @@ t.describe('lock.is_current', function() local s = assert(spec.parse({ 'owner/repo', tag = 'v1', opt = true })) t.falsy(make({ tag = 'v1', opt = false }):is_current(s)) end) + + t.it('is true when the tracked branch still matches', function() + local s = assert(spec.parse({ 'owner/repo', branch = 'main' })) + t.truthy(make({ branch = 'main', opt = false }):is_current(s)) + end) + + t.it('is false when the spec switched to another branch', function() + local s = assert(spec.parse({ 'owner/repo', branch = 'master' })) + t.falsy(make({ branch = 'main', opt = false }):is_current(s)) + end) + + t.it('is false when a spec swaps between a tag and a branch', function() + local tagged = assert(spec.parse({ 'owner/repo', tag = 'v1' })) + local branched = assert(spec.parse({ 'owner/repo', branch = 'main' })) + t.falsy(make({ branch = 'main', opt = false }):is_current(tagged)) + t.falsy(make({ tag = 'v1', opt = false }):is_current(branched)) + end) +end) + +t.describe('lock.needs_install', function() + local function make(entry) + local lock = Lock.new(tmpdir() .. '/lock.json') + if entry then + lock:set('repo', entry) + end + return lock + end + + local tagged = assert(spec.parse({ 'owner/repo', tag = 'v1' })) + local branched = assert(spec.parse({ 'owner/repo', branch = 'main' })) + + t.it('installs anything that is missing from the lockfile', function() + t.truthy(make(nil):needs_install(tagged, { present = false })) + t.truthy(make(nil):needs_install(branched, { present = false })) + end) + + t.it('installs anything whose directory is gone', function() + t.truthy(make({ tag = 'v1' }):needs_install(tagged, { present = false })) + t.truthy(make({ branch = 'main' }):needs_install(branched, { present = false })) + end) + + t.it('skips an installed tag, which cannot have moved', function() + t.falsy(make({ tag = 'v1' }):needs_install(tagged, { present = true })) + t.falsy(make({ tag = 'v1' }):needs_install(tagged, { present = true, update = true })) + end) + + t.it('leaves an installed branch alone on a plain sync', function() + t.falsy(make({ branch = 'main' }):needs_install(branched, { present = true })) + end) + + t.it('refetches an installed branch on an update run', function() + t.truthy(make({ branch = 'main' }):needs_install(branched, { present = true, update = true })) + end) + + t.it('refetches everything when forced', function() + t.truthy(make({ tag = 'v1' }):needs_install(tagged, { present = true, force = true })) + t.truthy(make({ branch = 'main' }):needs_install(branched, { present = true, force = true })) + end) + + t.it('installs a branch whose name changed even without an update run', function() + local other = assert(spec.parse({ 'owner/repo', branch = 'master' })) + t.truthy(make({ branch = 'main' }):needs_install(other, { present = true })) + end) end) diff --git a/tests/spec_spec.lua b/tests/spec_spec.lua index 5076f18..e3618e0 100644 --- a/tests/spec_spec.lua +++ b/tests/spec_spec.lua @@ -35,6 +35,55 @@ t.describe('spec.parse', function() t.matches('missing a tag', err) end) + t.it('parses a branch spec', function() + local s = assert(spec.parse({ 'hrsh7th/cmp-path', branch = 'main' })) + t.eq('main', s.branch) + t.eq('main', s.ref) + t.eq(nil, s.tag, 'a branch spec carries no tag') + t.eq('cmp-path', s.name) + end) + + t.it('rejects a spec that sets both a tag and a branch', function() + local s, err = spec.parse({ 'owner/repo', tag = 'v1', branch = 'main' }) + t.falsy(s) + t.matches('sets both a tag and a branch', err) + end) + + t.it('rejects a spec with neither a tag nor a branch', function() + local s, err = spec.parse({ 'owner/repo' }) + t.falsy(s) + t.matches('missing a tag or a branch', err) + end) + + t.it('rejects a branch that would reshape the archive path', function() + for _, bad in ipairs({ '..', '.', 'feature/foo', '../../etc', 'a\\b', 'ma in' }) do + local s, err = spec.parse({ 'owner/repo', branch = bad }) + t.falsy(s, 'rejects ' .. vim.inspect(bad)) + t.matches('invalid branch', err) + end + end) + + t.it('rejects an empty branch', function() + for _, bad in ipairs({ '', ' ', 42 }) do + local s, err = spec.parse({ 'owner/repo', branch = bad }) + t.falsy(s, 'rejects ' .. vim.inspect(bad)) + t.matches('missing a tag or a branch', err) + end + end) + + t.it('rejects a tag that would reshape the archive path', function() + for _, bad in ipairs({ '..', '.', 'a\\b' }) do + local s, err = spec.parse({ 'owner/repo', tag = bad }) + t.falsy(s, 'rejects ' .. vim.inspect(bad)) + t.matches('invalid tag', err) + end + end) + + t.it('trims surrounding whitespace from a branch', function() + local s = assert(spec.parse({ 'owner/repo', branch = ' main \n' })) + t.eq('main', s.branch) + end) + t.it('rejects a source without an owner', function() local s, err = spec.parse({ 'plenary.nvim', tag = 'v1' }) t.falsy(s) @@ -121,6 +170,26 @@ t.describe('spec.url', function() end end) + t.it('builds a branch archive url', function() + local s = assert(spec.parse({ 'hrsh7th/cmp-path', branch = 'main' })) + t.eq(REGISTRY .. '/hrsh7th/cmp-path/archive/refs/heads/main.zip', spec.url(s, REGISTRY)) + end) + + t.it('builds a branch archive url for tar.gz', function() + local s = assert(spec.parse({ 'hrsh7th/vim-vsnip', branch = 'master', ext = 'tar.gz' })) + t.eq(REGISTRY .. '/hrsh7th/vim-vsnip/archive/refs/heads/master.tar.gz', spec.url(s, REGISTRY)) + end) + + t.it('does not double up on a trailing registry slash for a branch', function() + local s = assert(spec.parse({ 'owner/repo', branch = 'main' })) + t.eq(REGISTRY .. '/owner/repo/archive/refs/heads/main.zip', spec.url(s, REGISTRY .. '///')) + end) + + t.it('lets an explicit url bypass the registry for a branch', function() + local s = assert(spec.parse({ 'owner/repo', branch = 'main', url = 'https://example.test/main.zip' })) + t.eq('https://example.test/main.zip', spec.url(s, REGISTRY)) + end) + t.it('passes an explicit url straight through', function() local s = assert(spec.parse({ 'owner/repo', tag = 'v1', url = 'https://example.test/a.zip' })) t.eq('https://example.test/a.zip', spec.url(s, REGISTRY)) diff --git a/tests/zipfile.lua b/tests/zipfile.lua new file mode 100644 index 0000000..028b014 --- /dev/null +++ b/tests/zipfile.lua @@ -0,0 +1,76 @@ +local zzlib = require('arti-pack.vendor.zzlib') + +local M = {} + +local function le(n, bytes) + local out = {} + for _ = 1, bytes do + table.insert(out, string.char(n % 256)) + n = math.floor(n / 256) + end + return table.concat(out) +end + +-- A stored-entry zip built here rather than shelled out to the zip binary, so +-- tests that need a real archive run wherever nvim does. +function M.bytes(entries) + local locals, central, offset = {}, {}, 0 + for _, e in ipairs(entries) do + local data = e.data or '' + local crc = zzlib.crc32(data) + local head = 'PK\3\4' + .. le(20, 2) + .. le(0, 2) + .. le(0, 2) + .. le(0, 4) + .. le(crc, 4) + .. le(#data, 4) + .. le(#data, 4) + .. le(#e.name, 2) + .. le(0, 2) + .. e.name + table.insert(locals, head .. data) + table.insert( + central, + 'PK\1\2' + .. le(3 * 256 + 20, 2) + .. le(20, 2) + .. le(0, 2) + .. le(0, 2) + .. le(0, 4) + .. le(crc, 4) + .. le(#data, 4) + .. le(#data, 4) + .. le(#e.name, 2) + .. le(0, 2) + .. le(0, 2) + .. le(0, 2) + .. le(0, 2) + .. le(0, 4) + .. le(offset, 4) + .. e.name + ) + offset = offset + #head + #data + end + local body = table.concat(locals) + local cd = table.concat(central) + local eocd = 'PK\5\6' + .. le(0, 2) + .. le(0, 2) + .. le(#entries, 2) + .. le(#entries, 2) + .. le(#cd, 4) + .. le(#body, 4) + .. le(0, 2) + return body .. cd .. eocd +end + +-- Mirrors an upstream source archive: everything under one "-" root. +function M.write(path, root, marker) + local fd = assert(io.open(path, 'wb')) + fd:write(M.bytes({ { name = root .. '/lua/marker.lua', data = 'return "' .. marker .. '"' } })) + fd:close() + return path +end + +return M -- 2.47.3 From 3c73a83602eaf7d035b61cbe7d95d234d587b8cf Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 12 Sep 2026 00:23:08 +1000 Subject: [PATCH 2/3] Reject a NUL byte in every ref that reaches curl A NUL truncates a string when vim.system marshals argv, so the URL curl fetches diverges from the ref that was validated, locked and displayed. The whitespace class does not cover it. - reject a NUL in tag, branch, the "as" directory name, an explicit url and the registry - cover a NUL in the middle, at the end, and alone for each --- lua/arti-pack/spec.lua | 19 +++++++++++++++ tests/spec_spec.lua | 55 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/lua/arti-pack/spec.lua b/lua/arti-pack/spec.lua index 69af109..ae6d5c3 100644 --- a/lua/arti-pack/spec.lua +++ b/lua/arti-pack/spec.lua @@ -9,6 +9,13 @@ local function trim(s) return (s:gsub('^%s+', ''):gsub('%s+$', '')) end +-- vim.system hands argv to the OS as C strings, so a NUL truncates the value +-- before curl sees it: the URL fetched would not be the one validated, locked +-- and shown. Lua's %s class does not cover it, so it needs its own check. +local function has_nul(s) + return s:find('\0', 1, true) ~= nil +end + local function bad_name(name) return name == '' or name == '.' or name == '..' or name:find('[/\\%s]') ~= nil end @@ -44,6 +51,9 @@ function M.parse(entry) return nil, string.format('%s is missing a tag or a branch', source) end ref = trim(ref) + if has_nul(ref) then + return nil, string.format('%s has a NUL byte in its %s %s', source, kind, vim.inspect(ref)) + end if bad_name(ref) then return nil, string.format('%s has an invalid %s %q', source, kind, ref) end @@ -54,6 +64,9 @@ function M.parse(entry) end local name = entry.as or repo + if type(name) == 'string' and has_nul(name) then + return nil, string.format('%s has a NUL byte in its directory name %s', source, vim.inspect(name)) + end if type(name) ~= 'string' or bad_name(name) then return nil, string.format('%s resolves to an unusable directory name %q', source, tostring(name)) end @@ -64,6 +77,9 @@ function M.parse(entry) return nil, string.format('%s has a non-string url', source) end url = trim(url) + if has_nul(url) then + return nil, string.format('%s has a NUL byte in its url %s', source, vim.inspect(url)) + end if url == '' or url:find('%s') then return nil, string.format('%s has an invalid url %q', source, entry.url) end @@ -96,6 +112,9 @@ function M.check_registry(registry) end -- Whitespace left inside the URL would be baked into every download. local base = trim(registry) + if has_nul(base) then + return nil, string.format('registry %s has a NUL byte in it; %s', vim.inspect(registry), SETUP_HINT) + end if not base:match('^https?://[^%s/]') or base:find('%s') then return nil, bad end diff --git a/tests/spec_spec.lua b/tests/spec_spec.lua index e3618e0..5f7554c 100644 --- a/tests/spec_spec.lua +++ b/tests/spec_spec.lua @@ -2,6 +2,11 @@ local t = require('tests.harness') local spec = require('arti-pack.spec') local REGISTRY = 'https://artifacts.example.com/artifactory/github-remote' +local NUL = string.char(0) + +-- A NUL truncates the value in argv, so anything that reaches curl must be +-- rejected before it can diverge from what was validated and locked. +local NULS = { 'mid' .. NUL .. 'dle', 'trailing' .. NUL, NUL } t.describe('spec.parse', function() t.it('parses a full table spec', function() @@ -131,6 +136,47 @@ t.describe('spec.parse', function() t.eq('https://example.test/a.zip', s.url) end) + t.it('rejects a tag carrying a NUL byte', function() + for _, bad in ipairs(NULS) do + local s, err = spec.parse({ 'owner/repo', tag = bad }) + t.falsy(s, 'rejects ' .. vim.inspect(bad)) + t.matches('NUL byte in its tag', err) + end + end) + + t.it('rejects a branch carrying a NUL byte', function() + for _, bad in ipairs(NULS) do + local s, err = spec.parse({ 'owner/repo', branch = bad }) + t.falsy(s, 'rejects ' .. vim.inspect(bad)) + t.matches('NUL byte in its branch', err) + end + end) + + t.it('rejects an "as" name carrying a NUL byte', function() + for _, bad in ipairs(NULS) do + local s, err = spec.parse({ 'owner/repo', tag = 'v1', as = bad }) + t.falsy(s, 'rejects ' .. vim.inspect(bad)) + t.matches('NUL byte in its directory name', err) + end + end) + + t.it('rejects a url carrying a NUL byte', function() + local urls = { 'https://example.test/a' .. NUL .. 'b.zip', 'https://example.test/a.zip' .. NUL, NUL } + for _, bad in ipairs(urls) do + local s, err = spec.parse({ 'owner/repo', tag = 'v1', url = bad }) + t.falsy(s, 'rejects ' .. vim.inspect(bad)) + t.matches('NUL byte in its url', err) + end + end) + + t.it('rejects a source carrying a NUL byte', function() + for _, bad in ipairs(NULS) do + local s, err = spec.parse({ 'owner/' .. bad, tag = 'v1' }) + t.falsy(s, 'rejects ' .. vim.inspect(bad)) + t.matches('expected "owner/repo"', err) + end + end) + t.it('rejects a url with whitespace inside it', function() for _, bad in ipairs({ 'https://example.test/a b.zip', 'https://example\n.test/a.zip', ' ' }) do local s, err = spec.parse({ 'owner/repo', tag = 'v1', url = bad }) @@ -242,6 +288,15 @@ t.describe('spec.check_registry', function() end end) + t.it('rejects a registry carrying a NUL byte', function() + local bads = { 'https://artifacts.example.com/g' .. NUL .. 'h', REGISTRY .. NUL, NUL } + for _, bad in ipairs(bads) do + local base, err = spec.check_registry(bad) + t.falsy(base, 'rejects ' .. vim.inspect(bad)) + t.matches('NUL byte', err) + end + end) + t.it('rejects a whitespace-only registry', function() for _, bad in ipairs({ ' ', ' ', '\n', ' \t\n ' }) do local base, err = spec.check_registry(bad) -- 2.47.3 From 3716131e56f2a04a74e177bf486f7169d522a3bb Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 12 Sep 2026 00:23:08 +1000 Subject: [PATCH 3/3] Say the recorded digest is not a file checksum sha256() refuses binary input, so the archive is hashed in its base64 form. Comparing it against sha256sum reads as corruption. --- README.md | 4 ++++ doc/arti-pack.txt | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/README.md b/README.md index 29b9541..b6a3424 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,10 @@ hrsh7th/cmp-path @main start tracking branch main, fetc nvim-lua/plenary.nvim v0.1.4 start installed ``` +The digest is a content identity, not a file checksum: Neovim's `sha256()` +refuses binary input, so it hashes the archive's base64 form. It will not match +`sha256sum `. + A branch plugin is never reported as up to date: nothing on disk can know whether upstream has moved. After an update, an unchanged digest is reported as `unchanged` rather than as an update that changed nothing. diff --git a/doc/arti-pack.txt b/doc/arti-pack.txt index 2cf50fd..79eb818 100644 --- a/doc/arti-pack.txt +++ b/doc/arti-pack.txt @@ -108,6 +108,10 @@ one. |:ArtiPackList| reports that rather than guessing: > hrsh7th/cmp-path @main start tracking branch main, fetched 2026-09-08 10:14 (7db8f64a) < +The digest is a content identity, not a file checksum: Neovim's sha256() +refuses binary input, so it hashes the archive's base64 form. It will not +match sha256sum on the archive. + A branch plugin is never reported as up to date, because nothing on disk can know whether upstream has moved. An update that brings back the same digest is reported as unchanged, not as an update. -- 2.47.3