lib.ec.secret_store.TarGpg: Add the write path
All checks were successful
CI / Packaging - Kali Linux (pull_request) Successful in 4m32s
CI / Packaging - OpenSUSE Tumbleweed (pull_request) Successful in 4m52s
CI / Packaging test (pull_request) Successful in 0s
CI / Packaging - Kali Linux (push) Successful in 4m35s
CI / Packaging - OpenSUSE Tumbleweed (push) Successful in 5m17s
CI / Packaging test (push) Successful in 0s

The TarGpg store is read-only: put() and unlink() raise, so the store can
be copied from, but not into, and a copy that lands entries and blobs below
a host prefix has no tarball target.

This matches the way jw-pkg secrets has worked for a long time. Since
Stores are now FileContext-backed, a write path can be cleanly implemented,
and that's what this commit does.

A put rebuilds the tarball: the existing members minus the removed, plus
the written one; the result is signed with the recipient from
JW_SECRETS_TARGPG_RECIPIENT and written back to the tarball location, which
must be a local path. A missing tarball is an empty store, so a put creates
it.

The put dispatch follows the other stores: a typed entry put replaces the
entry with the given serialized content, a key put merges into its entry,
creating it if it does not exist yet, and an untyped put below an entry
lands a key there, otherwise a blob. unlink() removes the node's member.

Signed-off-by: Jan Lindemann <jan@janware.com>
Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v1.1.0
This commit is contained in:
Jan Lindemann 2026-10-04 19:09:20 +02:00
commit f70cc48eb6
Signed by: Jan Lindemann
GPG key ID: 3750640C9E25DD61

View file

@ -4,14 +4,16 @@ import errno
import io
import os
import stat
import tempfile
import time
from tarfile import TarFile, TarInfo
from typing import TYPE_CHECKING, Iterable, override
from ...file_types import FT_DIR
from ...kv import parse_kv
from ...kv import parse_kv, serialize_kv
from ...Result import Result
from ...Uri import Uri
from .Base import Base
from .layout import FT_SECRET, FT_SECRET_FILE, FT_SECRET_KEY
@ -19,7 +21,7 @@ if TYPE_CHECKING:
from typing import Any
from ...base import StatResult
from ...Uri import Uri
from ...file_types import FileType
class TarGpg(Base):
"""A store of secrets in a GPG-encrypted tarball in the canonical
@ -40,6 +42,10 @@ class TarGpg(Base):
The tarball is fetched and decrypted once, then cached. Fetching,
decrypting, and filtering happen in a local exec context, so the
GPG key and the decrypted material stay on this machine.
A write rebuilds the tarball, signs it, and writes it back to
the tarball location, which must be a local path; the recipient
is deployment information, JW_SECRETS_TARGPG_RECIPIENT.
"""
def __init__(self, uri: str | Uri, *args: Any, **kwargs: Any) -> None:
@ -225,3 +231,128 @@ class TarGpg(Base):
async def _stat(self, path: str, follow_symlinks: bool) -> StatResult:
path = self._unchroot(path)
return await self._call_with_fallback(path, self.__tar_stat)
async def __tar_write(self, added: dict[str, bytes], removed: set[str]) -> None:
# -- Rebuild the tarball: the existing members minus the
# removed, plus the added; the result is signed and
# written back to the tarball location, which must be a
# local path. A missing tarball is an empty store.
try:
tf = await self.__tar()
except FileNotFoundError:
tf = None
data: dict[str, tuple[TarInfo, bytes]] = {}
if tf is not None:
for name, info in {m.name: m for m in tf.getmembers()}.items():
if name in removed:
continue
f = tf.extractfile(info)
if f is None:
raise Exception(f'Can\'t read store member "{name}"')
data[name] = (info, f.read())
buf = io.BytesIO()
with TarFile(fileobj = buf, mode = 'w') as out:
for name, (info, content) in data.items():
out.addfile(info, io.BytesIO(content))
for name, content in added.items():
info = TarInfo(name)
info.size = len(content)
info.mode = 0o400
out.addfile(info, io.BytesIO(content))
u = Uri(self.__tar_uri)
if u.scheme not in ('', 'local'):
raise Exception(f'TarGpg: cannot write to "{self.__tar_uri}"')
recipient = os.environ.get('JW_SECRETS_TARGPG_RECIPIENT', '')
if not recipient:
raise Exception('TarGpg: JW_SECRETS_TARGPG_RECIPIENT is not set')
fd, tar_tmp = tempfile.mkstemp()
with os.fdopen(fd, 'wb') as f:
f.write(buf.getvalue())
from ..Local import Local
await Local().run(
[
'gpg',
'--batch',
'--yes',
'--quiet',
'--no-tty',
'--trust-model',
'always',
'--recipient',
recipient,
'--encrypt',
'--output',
u.path or self.__tar_uri,
tar_tmp,
],
throw = True,
)
os.unlink(tar_tmp)
self.__tf = None
self.__kv_cache.clear()
async def __put_entry(self, entry: str, content: bytes) -> Result:
# -- Replace the entry: the content is the serialized
# entry, so a key not in it is gone
self.__kv_cache.pop(entry, None)
await self.__tar_write({entry: content}, set())
return Result()
async def __put_key(self, entry: str, key: str, content: bytes) -> Result:
# -- Merge the key into the entry, creating the entry if it
# does not exist yet
self.__kv_cache.pop(entry, None)
try:
kv = await self.__entry_kv(entry)
except FileNotFoundError:
kv = {}
kv[key] = content.decode()
await self.__tar_write({entry: serialize_kv(kv)}, set())
return Result()
async def __put_blob(self, path: str, content: bytes) -> Result:
await self.__tar_write({path: content}, set())
return Result()
@override
async def _put(
self,
path: str,
content: bytes,
wd: str | None,
throw: bool,
verbose: bool | None,
title: str,
owner: str | None,
group: str | None,
mode: str | None,
atomic: bool,
ftype: FileType,
) -> Result:
# -- A write does not take the default fallback
path = self._unchroot(path)
storage_path = self._path_in_storage(path)
if not storage_path:
raise ValueError(f'put: "{path}" is not an entry/key or blob path')
if ftype.is_subtype(FT_SECRET):
return await self.__put_entry(storage_path, content)
if ftype.is_subtype(FT_SECRET_KEY):
entry, sep, key = storage_path.rpartition('/')
if not sep or not key:
raise ValueError(f'put: "{path}" is not of the form path/key')
return await self.__put_key(entry, key, content)
if ftype.is_subtype(FT_SECRET_FILE):
return await self.__put_blob(storage_path, content)
raise ValueError(f'put: ftype {ftype} is not a store content type')
@override
async def _unlink(self, path: str) -> None:
# -- Remove the node's member; the path names the node
path = self._unchroot(path)
storage_path = self._path_in_storage(path)
members = await self.__members()
if storage_path in members:
self.__kv_cache.pop(storage_path, None)
await self.__tar_write({}, {storage_path})
else:
raise FileNotFoundError(path)