diff --git a/README.md b/README.md index e76cc9a..b6a3424 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,70 @@ 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 +``` + +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. ## 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..79eb818 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,71 @@ 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) +< +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. + +============================================================================== +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 +134,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..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 @@ -32,13 +39,23 @@ 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 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 local ext = entry.ext or 'zip' @@ -47,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 @@ -57,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 @@ -67,7 +90,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, @@ -87,14 +112,18 @@ 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 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 +138,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..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() @@ -35,6 +40,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) @@ -82,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 }) @@ -121,6 +216,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)) @@ -173,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) 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