8 Commits

Author SHA1 Message Date
benvin f9cfe081c8 Merge pull request 'Load the working tree, not an installed arti-pack' (#9) from benvin/hermetic-test-harness into main
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #9
2026-09-12 12:54:45 +10:00
unkin-agent 1568334840 Load the working tree, not an installed arti-pack
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
Neovim's runtimepath searcher outranks package.path, so `make test` on a
machine with arti-pack installed tests the installed copy.

- Give the repository the first package loader slot in the test harness
- Run the suite under `nvim --clean` so site packages stay off the rtp
- Assert modules resolve inside the repository, even with a copy on the rtp
2026-09-12 12:52:55 +10:00
benvin c4eb84b34f Merge pull request 'Fetch the ref and url the spec actually names' (#8) from benvin/url-significant-refs into main
Reviewed-on: #8
2026-09-12 09:49:42 +10:00
unkin-agent e5a260ad25 Fetch the ref and url the spec actually names
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
A ref goes into the archive URL verbatim and an explicit url goes to curl
as a positional, so both could name one thing and fetch another.

- reject "#", "?", "%", "[", "]", "{" and "}" in a tag or branch, which
  curl or the server would turn into a different path
- pass "--" before the url so curl cannot read it as an option
- document the ref rules in README and the help file
2026-09-12 01:04:53 +10:00
benvin c06ac229af Merge pull request 'Install plugins from a branch' (#7) from benvin/branch-specs into main
Reviewed-on: #7
2026-09-12 00:44:59 +10:00
unkin-agent 3716131e56 Say the recorded digest is not a file checksum
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
sha256() refuses binary input, so the archive is hashed in its base64
form. Comparing it against sha256sum reads as corruption.
2026-09-12 00:23:08 +10:00
unkin-agent 3c73a83602 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
2026-09-12 00:23:08 +10:00
unkin-agent 460dc2c5c8 Install plugins from a branch
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
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/<branch> 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
2026-09-12 00:08:18 +10:00
18 changed files with 1028 additions and 85 deletions
+3 -1
View File
@@ -6,8 +6,10 @@ BARE_VERSION = $(patsubst v%,%,$(VERSION))
DIST = dist
ARCHIVE = $(DIST)/arti-pack-$(BARE_VERSION).zip
# --clean keeps the user's config and site packages off the runtimepath, so an
# installed arti-pack cannot stand in for the working tree.
test:
nvim --headless -l tests/run.lua
nvim --clean --headless -l tests/run.lua
lint:
luacheck lua tests
+76 -27
View File
@@ -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,30 @@ 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/<name>`, or `.../opt/<name>` 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 are `.` or `..`, or contain inner whitespace,
`/`, `\`, or any of `#`, `?`, `%`, `[`, `]`, `{`, `}` — each of those would
make curl fetch something other than the ref you named. A branch whose name
contains `/` therefore needs an explicit `url`.
### Options
@@ -98,32 +109,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 <archive>`.
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
```
<registry>/<owner>/<repo>/archive/refs/tags/<tag>.<ext>
<registry>/<owner>/<repo>/archive/refs/heads/<branch>.<ext>
```
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 `<repo>-<version>`
directory, which is stripped on the way in. Installed tags are recorded in
directory. Source archives wrap their contents in a single `<repo>-<ref>`
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.
+72 -20
View File
@@ -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,72 @@ 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 are "." or "..", or contain inner
whitespace, "/", "\", or any of "#", "?", "%", "[", "]", "{", "}" — each of
those would make curl fetch something other than the ref you named. A branch
name containing "/" therefore needs an explicit "url".
The archive URL is built as: >
<registry>/<owner>/<repo>/archive/refs/tags/<tag>.<ext>
<registry>/<owner>/<repo>/archive/refs/heads/<branch>.<ext>
<
==============================================================================
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 +135,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/<name> loaded at startup
stdpath('data')/site/pack/arti-pack/opt/<name> 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 "<repo>-<version>" 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 "<repo>-<ref>" 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:
+17
View File
@@ -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
+58 -10
View File
@@ -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
+35 -5
View File
@@ -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
@@ -92,6 +116,10 @@ local function download(ctx, spec, archive, done)
tostring(ctx.timeout),
'-o',
tmp,
-- curl reads a leading-dash positional as an option, so a spec url of
-- "--stderr=/path" or "--config=/path" would run as a flag instead of
-- being fetched. "--" ends option parsing.
'--',
url,
}
@@ -120,14 +148,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)
+25 -2
View File
@@ -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
+46 -12
View File
@@ -9,8 +9,20 @@ 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
-- A ref is spliced into the archive URL verbatim, so a character that is
-- significant to a URL makes the fetched path differ from the validated one:
-- "#" truncates at the fragment, "?" starts a query, "%" is decoded by the
-- server ("%2F" reopens the traversal "/" already blocks), and curl expands
-- "[]" and "{}" as globs into other URLs entirely.
local function bad_name(name)
return name == '' or name == '.' or name == '..' or name:find('[/\\%s]') ~= nil
return name == '' or name == '.' or name == '..' or name:find('[/\\%s#?%%%[%]{}]') ~= nil
end
function M.parse(entry)
@@ -32,13 +44,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 +69,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 +82,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 +95,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 +117,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 "<owner>/<repo>".
-- 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
-- "<owner>/<repo>".
function M.url(spec, registry)
if spec.url then
return spec.url
@@ -109,8 +143,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
+5 -1
View File
@@ -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()
+21
View File
@@ -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')
+26
View File
@@ -0,0 +1,26 @@
local M = { root = nil, sources = {} }
local uv = vim.uv or vim.loop
-- Neovim inserts its runtimepath searcher ahead of the package.path one, so an
-- installed copy of arti-pack resolves before the working tree. Claim the first
-- slot for the repository instead.
function M.install(root)
M.root = root
table.insert(package.loaders, 1, function(name)
local rel = name:gsub('%.', '/')
for _, candidate in ipairs({
root .. '/lua/' .. rel .. '.lua',
root .. '/lua/' .. rel .. '/init.lua',
root .. '/' .. rel .. '.lua',
}) do
if uv.fs_stat(candidate) then
M.sources[name] = candidate
return assert(loadfile(candidate))
end
end
return nil
end)
end
return M
+49
View File
@@ -0,0 +1,49 @@
local t = require('tests.harness')
local hermetic = require('tests.hermetic')
local MODULES = {
'arti-pack',
'arti-pack.spec',
'arti-pack.fs',
'arti-pack.archive',
'arti-pack.lock',
'arti-pack.installer',
}
local function shadow_plugin()
local dir = vim.fn.tempname()
vim.fn.mkdir(dir .. '/lua/arti-pack', 'p')
vim.fn.writefile({ 'return { shadowed = true }' }, dir .. '/lua/arti-pack/spec.lua')
vim.fn.writefile({ 'return { shadowed = true }' }, dir .. '/lua/arti-pack/init.lua')
return dir
end
t.describe('test harness', function()
t.it('loads every module from the repository', function()
for _, name in ipairs(MODULES) do
require(name)
local source = hermetic.sources[name]
t.truthy(source, name .. ' was not resolved by the repository loader')
t.eq(hermetic.root, source:sub(1, #hermetic.root), name .. ' resolved outside the repository')
end
end)
t.it('outranks an arti-pack installed on the runtimepath', function()
local dir = shadow_plugin()
vim.opt.runtimepath:prepend(dir)
local ok, err = pcall(function()
t.eq(dir .. '/lua/arti-pack/spec.lua', vim.api.nvim_get_runtime_file('lua/arti-pack/spec.lua', false)[1])
for _, name in ipairs({ 'arti-pack', 'arti-pack.spec' }) do
package.loaded[name] = nil
t.falsy(require(name).shadowed, name .. ' came from the runtimepath copy')
t.eq(hermetic.root, hermetic.sources[name]:sub(1, #hermetic.root))
end
end)
vim.opt.runtimepath:remove(dir)
vim.fn.delete(dir, 'rf')
assert(ok, err)
end)
end)
+164
View File
@@ -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' } })
+98
View File
@@ -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,103 @@ 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)
-- curl reads a leading-dash positional as an option, so a url that looks like a
-- flag must reach it as a url or not at all. These fixtures are built in Lua,
-- so they run with no zip or tar around.
t.describe('installer.run with a url that looks like a curl option', function()
t.it('does not let a url write a file of its choosing', function()
local ctx = new_ctx()
local victim = tmpdir() .. '/written-by-curl'
local s = assert(spec_mod.parse({ 'owner/sneaky', tag = 'v1', url = '--stderr=' .. victim }))
local results = run(ctx, { s })
t.eq('failed', results[1].status)
t.falsy(fs.exists(victim), 'the url ran as a curl option and created a file')
end)
t.it('does not let a url pull curl options in from a config file', function()
local ctx = new_ctx()
local archive = zipfile.write(tmpdir() .. '/sneaky-v1.zip', 'sneaky-v1', 'first')
local conf = write(tmpdir() .. '/curl.conf', string.format('url = "file://%s"\n', archive))
local s = assert(spec_mod.parse({ 'owner/sneaky', tag = 'v1', url = '--config=' .. conf }))
local results = run(ctx, { s })
t.eq('failed', results[1].status, 'curl fetched a url the spec never named')
t.falsy(fs.exists(ctx.pack_dir .. '/start/sneaky'), 'nothing was installed')
t.falsy(ctx.lock:get('sneaky'), 'nothing was recorded')
end)
t.it('still installs from a plain file:// url', function()
local ctx = new_ctx()
local archive = zipfile.write(tmpdir() .. '/honest-v1.zip', 'honest-v1', 'first')
local s = assert(spec_mod.parse({ 'owner/honest', tag = 'v1', url = 'file://' .. archive }))
local results = run(ctx, { s })
t.eq('installed', results[1].status, results[1].err)
t.truthy(fs.exists(ctx.pack_dir .. '/start/honest/lua/marker.lua'))
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)
+80
View File
@@ -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)
+13 -7
View File
@@ -1,14 +1,20 @@
local root = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':p:h:h')
package.path = table.concat({
root .. '/lua/?.lua',
root .. '/lua/?/init.lua',
root .. '/?.lua',
package.path,
}, ';')
local hermetic = dofile(root .. '/tests/hermetic.lua')
package.loaded['tests.hermetic'] = hermetic
hermetic.install(root)
local t = require('tests.harness')
for _, name in ipairs({ 'spec_spec', 'fs_spec', 'archive_spec', 'lock_spec', 'installer_spec', 'init_spec' }) do
for _, name in ipairs({
'hermetic_spec',
'spec_spec',
'fs_spec',
'archive_spec',
'lock_spec',
'installer_spec',
'init_spec',
}) do
require('tests.' .. name)
end
+164
View File
@@ -2,6 +2,25 @@ 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 }
-- Each of these makes curl fetch something other than the ref it names: "#"
-- drops the rest of the path, "?" turns it into a query, "%" is decoded by the
-- server, and "[]"/"{}" are curl globs that fan out into other URLs.
local URL_SIGNIFICANT = {
'v1#frag',
'v1?q=1',
'v1%2e%2e',
'v1%2Fetc',
'v1[1-3]',
'v1{a,b}',
'v1]x',
'v1}x',
}
t.describe('spec.parse', function()
t.it('parses a full table spec', function()
@@ -35,6 +54,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 +150,73 @@ 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 tag that would change the fetched url', function()
for _, bad in ipairs(URL_SIGNIFICANT) 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('rejects a branch that would change the fetched url', function()
for _, bad in ipairs(URL_SIGNIFICANT) do
local s, err = spec.parse({ 'owner/repo', branch = bad })
t.falsy(s, 'rejects ' .. vim.inspect(bad))
t.matches('invalid branch', err)
end
end)
-- curl sends these through the path byte for byte, so they still name the ref
-- they were validated as. Rejecting them would lock out real upstream tags.
t.it('keeps accepting ref characters that reach the server unchanged', function()
for _, good in ipairs({ 'v1+2', 'v1@host', 'v1:2', 'release_1.2', 'v1~rc1', 'v1,2', 'v1;x', 'v1&x' }) do
local s = spec.parse({ 'owner/repo', tag = good })
t.truthy(s, 'accepts ' .. vim.inspect(good))
t.eq(REGISTRY .. '/owner/repo/archive/refs/tags/' .. good .. '.zip', spec.url(s, REGISTRY))
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 +256,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 +328,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)
+76
View File
@@ -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 "<repo>-<ref>" 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