lib.ec.secret_store: Add and use framework
All checks were successful
CI / Packaging - Kali Linux (pull_request) Successful in 4m48s
CI / Packaging - OpenSUSE Tumbleweed (pull_request) Successful in 5m12s
CI / Packaging test (pull_request) Successful in 0s
CI / Packaging - Kali Linux (push) Successful in 4m35s
CI / Packaging - OpenSUSE Tumbleweed (push) Successful in 4m49s
CI / Packaging test (push) Successful in 0s

"jw-pkg secrets install" copies secret material from a GPG-encrypted tar
archive to dotenv .jw-secret files on a target host. This commit refactors
the underlying code and adds hooks for the inverse copy direction, and for
more secret sources and targets.

The peers between which secrets are copied are represented by the new
abstract secret_store.Base class, and the existing tar blob and host
storages, inheriting from that class, are TarGpg and KvFile. Moreover,
secret_store.Base is inheriting from FileContext, so all of jw-pkg's
file-like operations are available as hooks.

Secrets inside instances of two file contexts are addressable as
(targpg|kvfile)://target.host.com/path/to/file.conf.jw-secret. The on-host
KvFile will look either locally or via SSH for that file, and TarGpg will
look inside a tar archive specified in the JW_SECRETS_TARGPG_TAR
environment variable.

Uris with these schemas can be passed to FileContext.create(), which will
return a chrooted file context of the correct type.

KvFile.stat("/path/to/file.conf.jw-secret").ftype is FT_SECRET which
inherits from FT_COMPOUND - which means that readdir() works on such
animals. Not entirely sure if that's a good idea, but it feels pretty
generic. Let's see where that takes us.

DistroContext.install() after this refactor reads its source through the
store interface and writes the landing through the KvFile store.

Both secret stores are exercised through the existing unit test of jw-pkg
secrets install, and unit tests for both stores are also added by this
commit.

Signed-off-by: Jan Lindemann <jan@janware.com>
This commit is contained in:
Jan Lindemann 2026-10-03 21:39:32 +02:00
commit f08f658d33
Signed by: Jan Lindemann
GPG key ID: 3750640C9E25DD61
7 changed files with 1835 additions and 169 deletions

View file

@ -0,0 +1,7 @@
TOPDIR = ../../../../../../../../../
include $(TOPDIR)/make/proj.mk
include $(JWBDIR)/make/py-run.mk
all:
test: run

View file

@ -0,0 +1,384 @@
import asyncio
import errno
import os
import stat
import tempfile
from typing import Any, override
from jw.pkg.lib.distros.suse.Distro import Distro as SuseDistro
from jw.pkg.lib.ec.Local import Local
from jw.pkg.lib.ec.secret_store.KvFile import KvFile
from jw.pkg.lib.ec.secret_store.layout import FT_SECRET, FT_SECRET_FILE, FT_SECRET_KEY
from jw.pkg.lib.file_types import FT_DIR
from jw.pkg.lib.FileContext import FileContext
from jw.pkg.lib.Package import Package
async def run_tests() -> None:
with tempfile.TemporaryDirectory() as tmp:
target = os.path.join(tmp, 'etc', 'foo.conf')
os.makedirs(os.path.dirname(target))
with open(target + '.jw-secret', 'w') as f:
f.write(
'# a comment\n\n key1 = value1 \nkey2=value2\n'
'=novalue\nkey3=\n'
)
# FileContext.create() resolves the kvfile scheme, keeping
# the URI as the store's identity
created = FileContext.create('kvfile://')
assert isinstance(created, KvFile)
assert created.uri.scheme == 'kvfile'
store = KvFile(Local())
assert set(await store.readdir(target)) == {'key1', 'key2', 'key3'}
assert (await store.get(f'{target}/key1')).stdout == b'value1'
assert (await store.get(f'{target}/key3')).stdout == b''
# The entry is gettable as a whole: the serialized file
assert (await store.get(target)).stdout == \
b'# a comment\n\n key1 = value1 \nkey2=value2\n=novalue\nkey3=\n'
# -- stat() answers the node's type, the metadata of the
# host file behind it; a key has no file of its own, and
# keeps the size 0
blob = os.path.join(tmp, 'etc', 'bar.pem')
with open(blob + '.jw-secret-file', 'w') as f:
f.write('a blob\n')
st = await store.stat(target)
assert st.ftype is FT_SECRET and stat.S_ISREG(st.mode)
assert st.is_gettable
assert st.size == os.path.getsize(target + '.jw-secret')
st = await store.stat(f'{target}/key1')
assert st.ftype is FT_SECRET_KEY and stat.S_ISREG(st.mode)
assert st.is_gettable
assert st.size == 0
st = await store.stat(blob)
assert st.ftype is FT_SECRET_FILE and stat.S_ISREG(st.mode)
assert st.is_gettable
assert st.size == os.path.getsize(blob + '.jw-secret-file')
st = await store.stat(os.path.dirname(target))
assert st.ftype is FT_DIR and stat.S_ISDIR(st.mode)
assert not st.is_gettable
st = await store.stat('')
assert st.ftype is FT_DIR and stat.S_ISDIR(st.mode)
assert not st.is_gettable
try:
await store.stat(f'{target}/missing')
raise AssertionError('expected FileNotFoundError')
except FileNotFoundError:
pass
# -- file_exists() works on the store, being built on stat()
assert await store.file_exists(target)
assert not await store.file_exists(f'{target}/missing')
# A missing key and a missing entry raise FileNotFoundError
for path in (f'{target}/missing', os.path.join(tmp, 'etc', 'missing.conf')):
try:
await store.get(path)
raise AssertionError(f'expected FileNotFoundError for {path}')
except FileNotFoundError:
pass
# put writes a key into the entry file, merging with the
# existing keys
await store.put(f'{target}/key4', b'value4', ftype = FT_SECRET_KEY)
assert (await store.get(f'{target}/key4')).stdout == b'value4'
assert (await store.get(f'{target}/key1')).stdout == b'value1'
# -- a second put overwrites the entry file, which is mode 0400
await store.put(f'{target}/key1', b'value1-new', ftype = FT_SECRET_KEY)
assert (await store.get(f'{target}/key1')).stdout == b'value1-new'
assert stat.S_IMODE(os.stat(target + '.jw-secret').st_mode) == 0o400
new_entry = os.path.join(tmp, 'etc', 'new.conf')
# -- a key put does not create: below a missing entry file
# it fails with EROFS; mkdir() is the materialization
try:
await store.put(f'{new_entry}/k', b'v', ftype = FT_SECRET_KEY)
raise AssertionError('expected EROFS')
except OSError as e:
assert e.errno == errno.EROFS
await store.mkdir(new_entry, ftype = FT_SECRET)
await store.put(f'{new_entry}/k', b'v', ftype = FT_SECRET_KEY)
with open(new_entry + '.jw-secret') as f:
assert f.read() == 'k=v\n'
# -- a typed put replaces the entry: the serialized content
# is written over the entry file, mode 0400
await store.put(target, b'new1=n1\nnew2=n2\n', ftype = FT_SECRET)
assert set(await store.readdir(target)) == {'new1', 'new2'}
assert (await store.get(target)).stdout == b'new1=n1\nnew2=n2\n'
with open(target + '.jw-secret') as f:
assert f.read() == 'new1=n1\nnew2=n2\n'
assert stat.S_IMODE(os.stat(target + '.jw-secret').st_mode) == 0o400
# -- a put that changes nothing does not rewrite the file
mtime = os.stat(target + '.jw-secret').st_mtime_ns
await store.put(target, b'new1=n1\nnew2=n2\n', ftype = FT_SECRET)
assert os.stat(target + '.jw-secret').st_mtime_ns == mtime
# -- An untyped put is refused: the store content types are
# entry, key, and blob
try:
await store.put('HOSTNAME', b'x')
raise AssertionError('expected ValueError')
except ValueError:
pass
# -- put: a blob below a branch lands as a .jw-secret-file at
# its config path, mode 0400
pem = os.path.join(tmp, 'etc', 'cert.pem.jw-secret-file')
await store.put(
os.path.join(tmp, 'etc', 'cert.pem'),
b'-----BEGIN CERT-----\n',
ftype = FT_SECRET_FILE,
)
with open(pem) as f:
assert f.read() == '-----BEGIN CERT-----\n'
assert stat.S_IMODE(os.stat(pem).st_mode) == 0o400
# -- mkdir: the type says which node to build, the default
# a branch; a branch is a real directory, an entry an
# empty entry file, mode 0400
await store.mkdir(os.path.join(tmp, 'etc'))
assert not os.path.exists(os.path.join(tmp, 'etc.jw-secret'))
baz = os.path.join(tmp, 'etc', 'baz')
await store.mkdir(baz)
assert os.path.isdir(baz)
bar = os.path.join(tmp, 'etc', 'bar.conf')
await store.mkdir(bar, ftype = FT_SECRET)
assert os.path.isfile(bar + '.jw-secret')
assert open(bar + '.jw-secret').read() == ''
assert stat.S_IMODE(os.stat(bar + '.jw-secret').st_mode) == 0o400
# -- mkdir and put fail with EROFS below a missing directory
for fn in (
lambda: store.mkdir(os.path.join(tmp, 'no', 'such', 'entry')),
lambda: store.put(
os.path.join(tmp, 'no', 'such', 'entry', 'key'),
b'x',
ftype = FT_SECRET_KEY, ),
):
try:
await fn()
raise AssertionError('expected EROFS')
except OSError as e:
assert e.errno == errno.EROFS
# -- put forwards owner and group to the host context: an
# unknown group fails the put
try:
await store.put(
f'{target}/key7', b'v7', ftype = FT_SECRET_KEY, group = 'no-such-group'
)
except AssertionError:
raise
except Exception as e:
assert 'no-such-group' in str(e)
else:
raise AssertionError('expected the put to fail')
# unlink drops the file the address names, and a missing
# file raises
await store.unlink(target + '.jw-secret')
assert not os.path.exists(target + '.jw-secret')
try:
await store.unlink(target + '.jw-secret')
raise AssertionError('expected FileNotFoundError')
except FileNotFoundError:
pass
# -- Branch listing: the config paths below a branch, the
# root included, come from the host's package set, the
# templates of the installed packages, presence-checked
# on the host. A fake distro supplies the list
class FakeDistro(SuseDistro):
def __init__(self, files: list[str]) -> None:
super().__init__(Local(), id = 'suse')
self.files = files
self.queries = 0
@override
async def _pkg_files(self, names: list[str] | None) -> list[str]:
self.queries += 1
return self.files
tree = os.path.join(tmp, 'tree')
os.makedirs(os.path.join(tree, 'etc'))
with open(os.path.join(tree, 'etc', 'foo.conf.jw-secret'), 'w') as f:
f.write('k1=v1\nk2=v2\n')
with open(os.path.join(tree, 'etc', 'cert.pem.jw-secret-file'), 'w') as f:
f.write('-----BEGIN CERT-----\n')
with open(os.path.join(tree, 'etc', 'other.conf.jw-secret'), 'w') as f:
f.write('o1=v1\n')
# -- A template without a secret file is not a config path;
# a package file that is not a template is not one
# either
fake = FakeDistro(
[
f'{tree}/etc/foo.conf.jw-tmpl',
f'{tree}/etc/cert.pem.jw-tmpl',
f'{tree}/etc/ghost.conf.jw-tmpl',
f'{tree}/etc/other.conf',
]
)
# -- The address space is rooted at the host root; the
# package file list reports host-absolute paths, so the
# fixture tree sits at its path below the root
bstore = KvFile(Local(), distro = fake)
base = tree.lstrip('/')
# -- is_dir answers from the host state, without consulting
# the package set
assert await bstore.is_dir('')
assert await bstore.is_dir(base)
assert await bstore.is_dir(f'{base}/etc/foo.conf')
assert not await bstore.is_dir(f'{base}/etc/cert.pem')
assert fake.queries == 0
# -- is_gettable answers from the node's type: an entry,
# a key, a blob; not a branch, the root included
assert await bstore.is_gettable(f'{base}/etc/foo.conf')
assert await bstore.is_gettable(f'{base}/etc/foo.conf/k1')
assert await bstore.is_gettable(f'{base}/etc/cert.pem')
assert not await bstore.is_gettable(f'{base}/etc')
assert not await bstore.is_gettable('')
assert fake.queries == 0
# -- Listing consults it, once: each branch lists the secret
# files below it, under their on-host file names,
# presence-checked
assert set(await bstore.readdir('')) == {'tmp'}
assert fake.queries == 1
assert set(await bstore.readdir(f'{base}/etc')) == \
{'cert.pem.jw-secret-file', 'foo.conf.jw-secret'}
assert fake.queries == 1
# -- An entry still lists its keys, without the package set
assert set(await bstore.readdir(f'{base}/etc/foo.conf')) == {'k1', 'k2'}
assert fake.queries == 1
# -- A branch without config paths raises FileNotFoundError
try:
await bstore.readdir('no/such')
raise AssertionError('expected FileNotFoundError')
except FileNotFoundError:
pass
# -- A real directory with no secret children lists empty
os.makedirs(os.path.join(tree, 'var', 'empty'))
assert set(await bstore.readdir(f'{base}/var/empty')) == set()
# -- The blob reads back through the store
assert (await bstore.get(f'{base}/etc/cert.pem')).stdout == \
b'-----BEGIN CERT-----\n'
# -- The entry's config path is also a deployed file on the
# host, as on a real host: a key below it has no host
# path, and is_dir() must not stat below the file
with open(os.path.join(tree, 'etc', 'foo.conf'), 'w') as f:
f.write('deployed\n')
assert not (await bstore.is_dir(f'{base}/etc/foo.conf/k1'))
assert (await bstore.get(f'{base}/etc/foo.conf/k1')).stdout == b'v1'
# -- A URI may root the store at a subtree of the host tree:
# create() keeps the URI as the store's identity, and its
# path is composed into every address. The inner context
# is host-rooted, so the on-host files are reached
# without a double path
created = FileContext.create(f'kvfile://{tree}/etc')
assert created.uri.path == f'{tree}/etc'
sstore = KvFile(Local(), uri = f'kvfile://{tree}/etc', distro = fake)
await sstore.open()
assert set(await sstore.readdir('')) == \
{'cert.pem.jw-secret-file', 'foo.conf.jw-secret'}
# -- An entry lists its keys, suffixed and unsuffixed alike
assert set(await sstore.readdir('foo.conf.jw-secret')) == {'k1', 'k2'}
assert set(await sstore.readdir('foo.conf')) == {'k1', 'k2'}
assert (await sstore.get('foo.conf/k1')).stdout == b'v1'
assert (await sstore.get('cert.pem')).stdout == \
b'-----BEGIN CERT-----\n'
# -- A suffixed address names the on-host file of its config
# path, the suffix picking the kind
assert (await sstore.get('foo.conf.jw-secret')).stdout == \
b'k1=v1\nk2=v2\n'
assert (await sstore.get('cert.pem.jw-secret-file')).stdout == \
b'-----BEGIN CERT-----\n'
st = await sstore.stat('foo.conf.jw-secret')
assert st.ftype is FT_SECRET
st = await sstore.stat('foo.conf.jw-secret/k1')
assert st.ftype is FT_SECRET_KEY
st = await sstore.stat('cert.pem.jw-secret-file')
assert st.ftype is FT_SECRET_FILE
# -- A blob address refuses to list
try:
await sstore.readdir('cert.pem.jw-secret-file')
raise AssertionError('expected NotADirectoryError')
except NotADirectoryError:
pass
# -- A write through the subtree root lands in the subtree
await sstore.mkdir('sub')
await sstore.mkdir('sub/conf', ftype = FT_SECRET)
await sstore.put('sub/conf/k', b'v', ftype = FT_SECRET_KEY)
with open(os.path.join(tree, 'etc', 'sub', 'conf.jw-secret')) as f:
assert f.read() == 'k=v\n'
await sstore.unlink('sub/conf.jw-secret')
assert not os.path.exists(os.path.join(tree, 'etc', 'sub', 'conf.jw-secret'))
await sstore.close()
# -- The autodetected distro applies the app's documented
# filter fallback: JW_DEFAULT_PKG_FILTER reaches
# Distro.instantiate() as the default package filter
from unittest import mock
from jw.pkg.lib.Distro import Distro
captured: dict[str, Any] = {}
async def __instantiate(*args: Any, **kwargs: Any) -> FakeDistro:
captured['kwargs'] = kwargs
return fake
os.environ['JW_DEFAULT_PKG_FILTER'] = 'url=~jw.dev'
try:
with mock.patch.object(Distro,
'instantiate',
new = classmethod(__instantiate)):
astore = KvFile(Local())
assert set(await astore.readdir('')) == {'tmp'}
flt = captured['kwargs'].get('default_pkg_filter')
assert flt is not None
assert flt.match(Package('fixture', url = 'https://jw.dev/fixture.rpm'))
assert not flt.match(
Package('other', url = 'https://elsewhere/other.rpm')
)
finally:
del os.environ['JW_DEFAULT_PKG_FILTER']
# -- The lifecycle propagates to the host context: an
# open() pin on the store keeps the host's transport
# open for its duration, and the run()s within it share
# it
class CountingLocal(Local):
def __init__(self) -> None:
super().__init__()
self.opens = 0
self.closes = 0
@override
async def _open(self) -> None:
self.opens += 1
await super()._open()
@override
async def _close(self) -> None:
self.closes += 1
await super()._close()
cl = CountingLocal()
pstore = KvFile(cl)
await pstore.open()
assert cl.opens == 1
await cl.run(['true'])
await cl.run(['true'])
assert cl.opens == 1
assert cl.closes == 0
await pstore.close()
assert cl.closes == 1
asyncio.run(run_tests())
print('All secrets kvfile store tests passed')

View file

@ -0,0 +1,4 @@
TOPDIR = ../../../../../../../../
include $(TOPDIR)/make/proj.mk
include $(JWBDIR)/make/dirs.mk

View file

@ -0,0 +1,7 @@
TOPDIR = ../../../../../../../../../
include $(TOPDIR)/make/proj.mk
include $(JWBDIR)/make/py-run.mk
all:
test: run

View file

@ -0,0 +1,235 @@
import asyncio
import io
import os
import shutil
import stat
import subprocess
import tarfile
import tempfile
from jw.pkg.lib.ec.secret_store.layout import FT_SECRET, FT_SECRET_FILE, FT_SECRET_KEY
from jw.pkg.lib.ec.secret_store.TarGpg import TarGpg
from jw.pkg.lib.file_types import FT_DIR
from jw.pkg.lib.FileContext import FileContext
from jw.pkg.lib.util import copy
def make_tar(path: str, entries: dict[str, str]) -> None:
buf = io.BytesIO()
with tarfile.open(fileobj = buf, mode = 'w') as tf:
for name, text in entries.items():
info = tarfile.TarInfo(name)
data = text.encode()
info.size = len(data)
info.mode = 0o400
tf.addfile(info, io.BytesIO(data))
with open(path, 'wb') as f:
f.write(buf.getvalue())
async def run_tests() -> None:
with tempfile.TemporaryDirectory() as tmp:
# -- A throw-away GPG home with a key for the test
gpghome = os.path.join(tmp, 'gnupg')
os.makedirs(gpghome, mode = 0o700)
env = dict(os.environ, GNUPGHOME = gpghome)
subprocess.run(
[
'gpg',
'--batch',
'--pinentry-mode',
'loopback',
'--passphrase',
'',
'--quick-gen-key',
'test@example.com',
],
check = True,
capture_output = True,
env = env,
)
# -- A store in the canonical layout, GPG-encrypted tarball
tar_path = os.path.join(tmp, 'secrets.tar')
make_tar(
tar_path,
{
'default/etc/a.jw-secret': 'a=1\n',
'default/etc/d.jw-secret': 'd=4\n',
'com/example/host/etc/a.jw-secret': 'a=2\n',
'com/example/host/etc/b.jw-secret': 'b=3\n',
# Non-key-value content is stored as a .jw-secret-file
# and addressable as a blob
'com/example/host/etc/c.pem.jw-secret-file': \
'-----BEGIN CERT-----\n',
# A member without a content suffix is store
# content too: non-key-value content is a blob
'com/example/host/etc/README.md': 'not key-value content\n',
}
)
enc_path = tar_path + '.gpg'
subprocess.run(
[
'gpg',
'--batch',
'--yes',
'--quiet',
'--no-tty',
'--trust-model',
'always',
'--recipient',
'test@example.com',
'--encrypt',
'--output',
enc_path,
tar_path,
],
check = True,
capture_output = True,
env = env,
)
# The store decrypts in a local exec context, which inherits
# the test's GNUPGHOME; the tarball location is deployment
# information, passed via the environment
os.environ['GNUPGHOME'] = gpghome
os.environ['JW_SECRETS_TARGPG_TAR'] = 'local://' + enc_path
# -- The whole store: strict, no fallback
store = FileContext.create('targpg://')
await store.open()
assert isinstance(store, TarGpg)
assert set(await store.readdir('')) == {'default', 'com'}
# -- The names as stored: the store does not strip or filter
# by member name, a suffixed name being the address of
# its node
assert set(await store.readdir('com/example/host/etc')) == \
{'a.jw-secret', 'b.jw-secret', 'c.pem.jw-secret-file',
'README.md'}
assert (await store.get('default/etc/a.jw-secret/a')).stdout == b'1'
assert (await store.get('com/example/host/etc/a.jw-secret/a')).stdout == \
b'2'
assert (await store.get('com/example/host/etc/b.jw-secret/b')).stdout == \
b'3'
# The entry is gettable as a whole: the member's content
assert (await store.get('default/etc/a.jw-secret')).stdout == b'a=1\n'
# A blob is a file: get() returns its bytes, readdir() refuses
pem = 'com/example/host/etc/c.pem.jw-secret-file'
assert (await store.get(pem)).stdout == b'-----BEGIN CERT-----\n'
try:
await store.readdir(pem)
raise AssertionError('expected NotADirectoryError')
except NotADirectoryError:
pass
# A member without a content suffix is store content too: a
# non-key-value member is a blob, listed, statable, gettable
assert 'README.md' in set(await store.readdir('com/example/host/etc'))
st = await store.stat('com/example/host/etc/README.md')
assert st.ftype is FT_SECRET_FILE
assert (await store.get('com/example/host/etc/README.md')).stdout == \
b'not key-value content\n'
# A missing entry and a missing key raise FileNotFoundError
for path in ('default/etc/missing', 'default/etc/a.jw-secret/missing'):
try:
await store.get(path)
raise AssertionError(f'expected FileNotFoundError for {path}')
except FileNotFoundError:
pass
# -- stat() answers the node's type, the metadata of the
# member behind it; a key has no member of its own, and a
# branch none at all, so they keep the size 0
st = await store.stat('')
assert st.ftype is FT_DIR and stat.S_ISDIR(st.mode)
st = await store.stat('default/etc/a.jw-secret')
assert st.ftype is FT_SECRET and stat.S_ISREG(st.mode)
assert st.size == len((await store.get('default/etc/a.jw-secret')).stdout)
st = await store.stat('default/etc/a.jw-secret/a')
assert st.ftype is FT_SECRET_KEY and stat.S_ISREG(st.mode)
assert st.size == 0
st = await store.stat('com/example/host/etc/c.pem.jw-secret-file')
assert st.ftype is FT_SECRET_FILE and stat.S_ISREG(st.mode)
assert st.size == len(
(await store.get('com/example/host/etc/c.pem.jw-secret-file')).stdout
)
st = await store.stat('default/etc')
assert st.ftype is FT_DIR and stat.S_ISDIR(st.mode)
assert st.size == 0
# -- is_dir answers the node's listability: an entry lists
# its keys, a branch its children; a key is a leaf
assert await store.is_dir('default/etc/a.jw-secret')
assert await store.is_dir('default/etc')
assert not await store.is_dir('default/etc/a.jw-secret/a')
try:
await store.stat('default/etc/missing')
raise AssertionError('expected FileNotFoundError')
except FileNotFoundError:
pass
# -- file_exists() works on the store, being built on stat()
assert await store.file_exists('default/etc/a.jw-secret')
assert not await store.file_exists('default/etc/missing')
await store.close()
# -- A host-rooted store applies the default fallback
store = FileContext.create('targpg://host.example.com')
assert isinstance(store, TarGpg)
await store.open()
assert (await store.get('etc/a.jw-secret/a')).stdout == b'2'
# A directory listing is the union with the default tree: d
# exists only under default/, but is listable as it is
# gettable
assert set(await store.readdir('etc')) == \
{'a.jw-secret', 'b.jw-secret', 'c.pem.jw-secret-file',
'd.jw-secret', 'README.md'}
# A miss below the host is retried below the default tree
assert (await store.get('etc/d.jw-secret/d')).stdout == b'4'
# A key listing stays host-wins-wholesale
assert set(await store.readdir('etc/d.jw-secret')) == {'d'}
# -- stat() distinguishes an entry from a branch and the root
st = await store.stat('etc/a.jw-secret')
assert st.ftype is FT_SECRET and stat.S_ISREG(st.mode)
st = await store.stat('etc')
assert st.ftype is FT_DIR and stat.S_ISDIR(st.mode)
st = await store.stat('')
assert st.ftype is FT_DIR and stat.S_ISDIR(st.mode)
await store.close()
# -- A store as a copy source: an entry lands as a single
# file with its serialized content, a branch as a
# directory, and a blob as a file; the default-only entry
# d lands from the union listing
host = os.path.join(tmp, 'host-tree')
os.makedirs(host)
ret = await copy('targpg://host.example.com', host, recursive = True)
assert isinstance(ret, list)
assert set(ret) == {
f'{host}/etc/a.jw-secret',
f'{host}/etc/b.jw-secret',
f'{host}/etc/c.pem.jw-secret-file',
f'{host}/etc/d.jw-secret',
f'{host}/etc/README.md',
}
with open(os.path.join(host, 'etc', 'a.jw-secret')) as f:
assert f.read() == 'a=2\n'
assert not os.path.exists(os.path.join(host, 'etc', 'a', 'a'))
with open(os.path.join(host, 'etc', 'c.pem.jw-secret-file')) as f:
assert f.read() == '-----BEGIN CERT-----\n'
# -- A single object that is an entry: the serialized whole
# lands at the destination
dst = os.path.join(tmp, 'copied', 'entry')
os.makedirs(os.path.dirname(dst))
ret = await copy('targpg://host.example.com/etc/a.jw-secret', dst)
assert ret == dst
with open(dst) as f:
assert f.read() == 'a=2\n'
def main() -> None:
if not shutil.which('gpg'):
print('Skipping secrets targpg store tests (gpg required)')
return
asyncio.run(run_tests())
print('All secrets targpg store tests passed')
main()

View file

@ -0,0 +1,7 @@
TOPDIR = ../../../../../../../../..
include $(TOPDIR)/make/proj.mk
include $(JWBDIR)/make/py-run.mk
all:
test: run

View file

@ -0,0 +1,41 @@
from jw.pkg.lib.ec.secret_store.layout import (
BLOB_SUFFIX,
DEFAULT_PREFIX,
FT_SECRET,
FT_SECRET_FILE,
FT_SECRET_KEY,
SECRET_SUFFIX,
content_name,
host_prefix,
)
from jw.pkg.lib.file_types import FT_COMPOUND, FT_DIR, FT_REG
# The layout constants are the store contract
assert DEFAULT_PREFIX == 'default'
assert SECRET_SUFFIX == '.jw-secret'
assert BLOB_SUFFIX == '.jw-secret-file'
# content_name strips the content suffix, and names without a
# content suffix are not store content
assert content_name('etc/a.jw-secret') == 'etc/a'
assert content_name('etc/c.pem.jw-secret-file') == 'etc/c.pem'
assert content_name('etc/README.md') is None
# An FQDN maps to its reversed, dot-split host root
assert host_prefix('host.example.com') == 'com/example/host'
assert host_prefix('a.b.c.d') == 'd/c/b/a'
# A single-label hostname maps to itself
assert host_prefix('localhost') == 'localhost'
# The store content types are registered with the file types: an
# entry is a kind of compound, a key and a blob a kind of file
assert FT_SECRET.is_subtype(FT_COMPOUND)
assert FT_SECRET.is_subtype(FT_REG)
assert not FT_SECRET.is_subtype(FT_DIR)
assert FT_SECRET_KEY.is_subtype(FT_REG)
assert not FT_SECRET_KEY.is_subtype(FT_DIR)
assert FT_SECRET_FILE.is_subtype(FT_REG)
assert not FT_SECRET_FILE.is_subtype(FT_DIR)
print('All secrets layout tests passed')