e5a260ad25
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
376 lines
14 KiB
Lua
376 lines
14 KiB
Lua
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()
|
|
local s = assert(spec.parse({ 'nvim-lua/plenary.nvim', tag = 'v0.1.4', opt = true }))
|
|
t.eq('nvim-lua', s.owner)
|
|
t.eq('plenary.nvim', s.repo)
|
|
t.eq('plenary.nvim', s.name)
|
|
t.eq('v0.1.4', s.tag)
|
|
t.eq('zip', s.ext)
|
|
t.eq(true, s.opt)
|
|
end)
|
|
|
|
t.it('defaults opt to false', function()
|
|
local s = assert(spec.parse({ 'tpope/vim-fugitive', tag = 'v3.7' }))
|
|
t.eq(false, s.opt)
|
|
end)
|
|
|
|
t.it('honours an "as" override for the install directory', function()
|
|
local s = assert(spec.parse({ 'folke/tokyonight.nvim', tag = 'v4.10.0', as = 'tokyonight' }))
|
|
t.eq('tokyonight', s.name)
|
|
end)
|
|
|
|
t.it('accepts a source key instead of the positional entry', function()
|
|
local s = assert(spec.parse({ source = 'owner/repo', tag = 'v1' }))
|
|
t.eq('owner/repo', s.source)
|
|
end)
|
|
|
|
t.it('rejects a bare string with no tag', function()
|
|
local s, err = spec.parse('owner/repo')
|
|
t.falsy(s)
|
|
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)
|
|
t.matches('expected "owner/repo"', err)
|
|
end)
|
|
|
|
t.it('rejects a source with extra path segments', function()
|
|
local s, err = spec.parse({ 'a/b/c', tag = 'v1' })
|
|
t.falsy(s)
|
|
t.matches('expected "owner/repo"', err)
|
|
end)
|
|
|
|
t.it('rejects a tag containing a path separator', function()
|
|
local s, err = spec.parse({ 'owner/repo', tag = '../../etc' })
|
|
t.falsy(s)
|
|
t.matches('invalid tag', err)
|
|
end)
|
|
|
|
t.it('rejects an unsupported extension', function()
|
|
local s, err = spec.parse({ 'owner/repo', tag = 'v1', ext = 'rar' })
|
|
t.falsy(s)
|
|
t.matches('unsupported ext', err)
|
|
end)
|
|
|
|
t.it('rejects a traversing "as" name', function()
|
|
local s, err = spec.parse({ 'owner/repo', tag = 'v1', as = '..' })
|
|
t.falsy(s)
|
|
t.matches('unusable directory name', err)
|
|
end)
|
|
|
|
t.it('rejects a non-table, non-string spec', function()
|
|
local s, err = spec.parse(42)
|
|
t.falsy(s)
|
|
t.matches('must be a string or a table', err)
|
|
end)
|
|
|
|
t.it('trims surrounding whitespace from the source and tag', function()
|
|
local s = assert(spec.parse({ ' owner/repo ', tag = ' v1.0.0 ' }))
|
|
t.eq('owner/repo', s.source)
|
|
t.eq('v1.0.0', s.tag)
|
|
end)
|
|
|
|
t.it('trims surrounding whitespace from an explicit url', function()
|
|
local s = assert(spec.parse({ 'owner/repo', tag = 'v1', url = ' https://example.test/a.zip\n' }))
|
|
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 })
|
|
t.falsy(s, 'rejects ' .. vim.inspect(bad))
|
|
t.matches('invalid url', err)
|
|
end
|
|
end)
|
|
end)
|
|
|
|
t.describe('spec.url', function()
|
|
t.it('builds a tag archive url', function()
|
|
local s = assert(spec.parse({ 'nvim-lua/plenary.nvim', tag = 'v0.1.4' }))
|
|
t.eq(REGISTRY .. '/nvim-lua/plenary.nvim/archive/refs/tags/v0.1.4.zip', spec.url(s, REGISTRY))
|
|
end)
|
|
|
|
t.it('honours the tar.gz extension', function()
|
|
local s = assert(spec.parse({ 'nvim-lua/plenary.nvim', tag = 'v0.1.4', ext = 'tar.gz' }))
|
|
t.matches('/v0%.1%.4%.tar%.gz$', spec.url(s, REGISTRY))
|
|
end)
|
|
|
|
t.it('does not double up on a trailing registry slash', function()
|
|
local s = assert(spec.parse({ 'owner/repo', tag = 'v1' }))
|
|
t.eq(REGISTRY .. '/owner/repo/archive/refs/tags/v1.zip', spec.url(s, REGISTRY .. '///'))
|
|
end)
|
|
|
|
t.it('never builds a url containing whitespace', function()
|
|
local s = assert(spec.parse({ 'owner/repo', tag = 'v1' }))
|
|
local want = REGISTRY .. '/owner/repo/archive/refs/tags/v1.zip'
|
|
t.eq(want, spec.url(s, REGISTRY .. ' '))
|
|
t.eq(want, spec.url(s, REGISTRY .. '\n'))
|
|
t.eq(want, spec.url(s, ' ' .. REGISTRY))
|
|
|
|
for _, bad in ipairs({ 'https://arti facts.example.com/gh', 'https://artifacts\n.example.com/gh', ' ' }) do
|
|
local url, err = spec.url(s, bad)
|
|
t.falsy(url, 'rejects ' .. vim.inspect(bad))
|
|
t.matches('is not an http%(s%) URL', err)
|
|
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))
|
|
end)
|
|
|
|
t.it('errors when no registry is configured', function()
|
|
local s = assert(spec.parse({ 'owner/repo', tag = 'v1' }))
|
|
local url, err = spec.url(s, nil)
|
|
t.falsy(url)
|
|
t.matches('registry is not configured', err)
|
|
t.matches("setup{ registry = 'https://", err, 'the error says how to set it')
|
|
end)
|
|
|
|
t.it('errors instead of handing a bad registry to curl', function()
|
|
local s = assert(spec.parse({ 'owner/repo', tag = 'v1' }))
|
|
for _, bad in ipairs({ '', 'https://', 'artifacts.example.com/gh', 'ftp://artifacts.example.com', 42 }) do
|
|
local url, err = spec.url(s, bad)
|
|
t.falsy(url, 'rejects ' .. vim.inspect(bad))
|
|
t.matches('is not an http%(s%) URL', err)
|
|
end
|
|
end)
|
|
end)
|
|
|
|
t.describe('spec.check_registry', function()
|
|
t.it('returns the registry with trailing slashes stripped', function()
|
|
t.eq(REGISTRY, spec.check_registry(REGISTRY))
|
|
t.eq(REGISTRY, spec.check_registry(REGISTRY .. '/'))
|
|
t.eq(REGISTRY, spec.check_registry(REGISTRY .. '///'))
|
|
end)
|
|
|
|
t.it('accepts a plain http registry', function()
|
|
t.eq('http://artifacts.example.com/gh', spec.check_registry('http://artifacts.example.com/gh'))
|
|
end)
|
|
|
|
t.it('trims leading whitespace', function()
|
|
t.eq(REGISTRY, spec.check_registry(' ' .. REGISTRY))
|
|
end)
|
|
|
|
t.it('trims trailing whitespace, including a newline', function()
|
|
t.eq(REGISTRY, spec.check_registry(REGISTRY .. ' '))
|
|
t.eq(REGISTRY, spec.check_registry(REGISTRY .. '\n'))
|
|
t.eq(REGISTRY, spec.check_registry(' \t' .. REGISTRY .. '/ \n'))
|
|
end)
|
|
|
|
t.it('rejects whitespace embedded in the registry', function()
|
|
for _, bad in ipairs({ 'https://artifacts.example.com/g h', 'https://artifacts\n.example.com/gh' }) do
|
|
local base, err = spec.check_registry(bad)
|
|
t.falsy(base, 'rejects ' .. vim.inspect(bad))
|
|
t.matches('is not an http%(s%) URL', err)
|
|
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)
|
|
t.falsy(base, 'rejects ' .. vim.inspect(bad))
|
|
t.matches('is not an http%(s%) URL', err)
|
|
end
|
|
end)
|
|
end)
|
|
|
|
t.describe('spec.parse_all', function()
|
|
t.it('collects specs and reports per-entry errors', function()
|
|
local specs, errors = spec.parse_all({
|
|
{ 'a/one', tag = 'v1' },
|
|
{ 'bad-source', tag = 'v1' },
|
|
{ 'b/two', tag = 'v2', opt = true },
|
|
})
|
|
t.eq(2, #specs)
|
|
t.eq(1, #errors)
|
|
t.matches('plugins%[2%]', errors[1])
|
|
end)
|
|
|
|
t.it('rejects two specs that install to the same directory', function()
|
|
local specs, errors = spec.parse_all({
|
|
{ 'a/shared', tag = 'v1' },
|
|
{ 'b/shared', tag = 'v2' },
|
|
})
|
|
t.eq(1, #specs)
|
|
t.matches('duplicate plugin name', errors[1])
|
|
end)
|
|
|
|
t.it('returns empty results for a nil plugin list', function()
|
|
local specs, errors = spec.parse_all(nil)
|
|
t.eq({}, specs)
|
|
t.eq({}, errors)
|
|
end)
|
|
end)
|