use std::collections::BTreeMap;
use std::fs::OpenOptions;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};
use arrow_array::builder::{BinaryBuilder, Int64Builder, StringBuilder};
use arrow_array::{Array, BinaryArray, RecordBatch, StringArray};
use arrow_ipc::reader::StreamReader;
use arrow_ipc::writer::{
write_message, DictionaryTracker, IpcDataGenerator, IpcWriteContext, IpcWriteOptions,
};
use arrow_schema::{DataType, Field, Schema, SchemaRef};
use gunnar_store::{Error, Result, SecretStore};
pub const REFS_LOG: &str = "refs.arrow";
pub const SECRETS_LOG: &str = "secrets.arrow";
pub const LOCK_FILE: &str = "gunnar-store.lock";
pub const REFS_ARCHIVE_DIR: &str = "refs-archive";
pub const COMPACTION_ACTOR: &str = "gunnar:compaction";
const DEFAULT_ACTOR: &str = "gunnar";
const CONTINUATION_MARKER: [u8; 4] = [0xff; 4];
pub(crate) const COL_REFNAME: &str = "refname";
pub(crate) const COL_OLD_OID: &str = "old_oid";
pub(crate) const COL_NEW_OID: &str = "new_oid";
pub(crate) const COL_OLD_SYMREF: &str = "old_symref";
pub(crate) const COL_NEW_SYMREF: &str = "new_symref";
pub(crate) const COL_ACTOR: &str = "actor";
pub(crate) const COL_AT: &str = "at_unix_ms";
const COL_KEY: &str = "key";
const COL_VALUE: &str = "value";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CompactionPolicy {
pub after_batches: usize,
pub reflog_window: usize,
}
impl CompactionPolicy {
pub fn new(after_batches: usize, reflog_window: usize) -> Self {
CompactionPolicy {
after_batches,
reflog_window,
}
}
pub fn never() -> Self {
CompactionPolicy {
after_batches: 0,
reflog_window: 0,
}
}
fn should_compact(&self, batches: usize) -> bool {
self.after_batches > 0 && batches >= self.after_batches
}
}
impl Default for CompactionPolicy {
fn default() -> Self {
CompactionPolicy {
after_batches: 1024,
reflog_window: 256,
}
}
}
pub(crate) fn ref_schema(hash_kind: gix_hash::Kind) -> SchemaRef {
let width = hash_kind.len_in_bytes() as i32;
Arc::new(Schema::new(vec![
Field::new(COL_REFNAME, DataType::Utf8, false),
Field::new(COL_OLD_OID, DataType::FixedSizeBinary(width), true),
Field::new(COL_NEW_OID, DataType::FixedSizeBinary(width), true),
Field::new(COL_OLD_SYMREF, DataType::Utf8, true),
Field::new(COL_NEW_SYMREF, DataType::Utf8, true),
Field::new(COL_ACTOR, DataType::Utf8, false),
Field::new(COL_AT, DataType::Int64, false),
]))
}
fn secret_schema() -> SchemaRef {
Arc::new(Schema::new(vec![
Field::new(COL_KEY, DataType::Utf8, false),
Field::new(COL_VALUE, DataType::Binary, true),
Field::new(COL_ACTOR, DataType::Utf8, false),
Field::new(COL_AT, DataType::Int64, false),
]))
}
fn hash_kind_of_width(width: i32) -> Option<gix_hash::Kind> {
match width {
20 => Some(gix_hash::Kind::Sha1),
32 => Some(gix_hash::Kind::Sha256),
_ => None,
}
}
pub(crate) fn check_schema(stored: &Schema, expected: &Schema) -> Result<()> {
if stored.fields() == expected.fields() {
return Ok(());
}
let width_of =
|schema: &Schema, name: &str| match schema.field_with_name(name).map(|f| f.data_type()) {
Ok(DataType::FixedSizeBinary(width)) => Some(*width),
_ => None,
};
if let (Some(found), Some(want)) = (
width_of(stored, COL_NEW_OID),
width_of(expected, COL_NEW_OID),
) {
if found != want {
if let (Some(stored_kind), Some(want_kind)) =
(hash_kind_of_width(found), hash_kind_of_width(want))
{
return Err(Error::HashKindMismatch {
stored: stored_kind,
requested: want_kind,
});
}
}
}
Err(Error::backend(format!(
"the log on disk has schema {stored:?}, which is not the schema this store writes ({expected:?})"
)))
}
pub(crate) fn valid_prefix_len(log: &[u8]) -> usize {
let mut pos = 0usize;
while let Some(total) = message_len(&log[pos..]) {
pos += total;
}
pos
}
pub(crate) fn message_len(rest: &[u8]) -> Option<usize> {
if rest.len() < 8 || rest[..4] != CONTINUATION_MARKER {
return None;
}
let meta_len = i32::from_le_bytes([rest[4], rest[5], rest[6], rest[7]]);
if meta_len <= 0 {
return None;
}
let meta_len = meta_len as usize;
if rest.len() < 8 + meta_len {
return None;
}
let message = arrow_ipc::root_as_message(&rest[8..8 + meta_len]).ok()?;
let body_len = usize::try_from(message.bodyLength()).ok()?;
let total = 8usize.checked_add(meta_len)?.checked_add(body_len)?;
if rest.len() < total {
return None;
}
Some(total)
}
enum Backing {
File { file: std::fs::File, path: PathBuf },
Memory { bytes: Arc<Mutex<Vec<u8>>> },
}
fn poisoned(what: &'static str) -> Error {
Error::backend(format!(
"the {what} lock was poisoned by a panicking writer"
))
}
fn compacting_path(path: &Path) -> PathBuf {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "log".to_owned());
path.with_file_name(format!("{name}.compacting"))
}
pub(crate) fn sync_parent_dir(path: &Path) -> Result<()> {
let parent = match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
_ => PathBuf::from("."),
};
std::fs::File::open(parent)?.sync_all()?;
Ok(())
}
pub(crate) fn write_options() -> IpcWriteOptions {
IpcWriteOptions::try_new(8, false, arrow_ipc::MetadataVersion::V5)
.expect("8 is a legal Arrow IPC write alignment")
}
pub(crate) fn encode_stream(
schema: &SchemaRef,
batches: &[RecordBatch],
options: &IpcWriteOptions,
) -> Result<Vec<u8>> {
let generator = IpcDataGenerator::default();
let mut tracker = DictionaryTracker::new(false);
let mut out = Vec::new();
let encoded = generator.schema_to_bytes_with_dictionary_tracker(schema, &mut tracker, options);
write_message(&mut out, encoded, options).map_err(arrow_err)?;
for batch in batches {
let (dictionaries, message) = generator
.encode(
batch,
&mut tracker,
options,
&mut IpcWriteContext::default(),
)
.map_err(arrow_err)?;
for dictionary in dictionaries {
write_message(&mut out, dictionary, options).map_err(arrow_err)?;
}
write_message(&mut out, message, options).map_err(arrow_err)?;
}
Ok(out)
}
impl Backing {
fn read_all(&self) -> Result<Vec<u8>> {
match self {
Backing::File { file, .. } => {
let mut handle = file;
handle.seek(SeekFrom::Start(0))?;
let mut out = Vec::new();
handle.read_to_end(&mut out)?;
Ok(out)
}
Backing::Memory { bytes } => {
Ok(bytes.lock().map_err(|_| poisoned("in-memory log"))?.clone())
}
}
}
fn truncate_to(&self, len: u64) -> Result<()> {
match self {
Backing::File { file, .. } => Ok(file.set_len(len)?),
Backing::Memory { bytes } => {
bytes
.lock()
.map_err(|_| poisoned("in-memory log"))?
.truncate(len as usize);
Ok(())
}
}
}
fn append(&self, buf: &[u8]) -> Result<()> {
match self {
Backing::File { file, .. } => {
let mut handle = file;
handle.seek(SeekFrom::End(0))?;
handle.write_all(buf)?;
handle.flush()?;
Ok(())
}
Backing::Memory { bytes } => {
bytes
.lock()
.map_err(|_| poisoned("in-memory log"))?
.extend_from_slice(buf);
Ok(())
}
}
}
fn sync(&self) -> Result<()> {
match self {
Backing::File { file, .. } => Ok(file.sync_all()?),
Backing::Memory { .. } => Ok(()),
}
}
fn replace_all(&mut self, bytes: &[u8], fsync: bool) -> Result<()> {
self.stage(bytes, fsync)?;
self.swap(bytes, fsync)
}
fn stage(&self, bytes: &[u8], fsync: bool) -> Result<()> {
let Backing::File { path, .. } = self else {
return Ok(());
};
let temporary = compacting_path(path);
let mut fresh = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&temporary)?;
fresh.write_all(bytes)?;
fresh.flush()?;
if fsync {
fresh.sync_all()?;
}
Ok(())
}
fn swap(&mut self, bytes: &[u8], fsync: bool) -> Result<()> {
match self {
Backing::File { file, path } => {
std::fs::rename(compacting_path(path), &*path)?;
if fsync {
sync_parent_dir(path)?;
}
*file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&*path)?;
Ok(())
}
Backing::Memory { bytes: cell } => {
let mut held = cell.lock().map_err(|_| poisoned("in-memory log"))?;
held.clear();
held.extend_from_slice(bytes);
Ok(())
}
}
}
}
impl std::fmt::Debug for Backing {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Backing::File { path, .. } => f.debug_struct("File").field("path", path).finish(),
Backing::Memory { .. } => f.write_str("Memory"),
}
}
}
#[derive(Debug, Default)]
struct Durability {
written: AtomicU64,
durable: AtomicU64,
written_seq: AtomicU64,
durable_seq: AtomicU64,
syncs: AtomicUsize,
fsync_nanos: AtomicU64,
}
struct SyncGate {
file: Mutex<Option<std::fs::File>>,
}
impl SyncGate {
fn detached() -> Self {
SyncGate {
file: Mutex::new(None),
}
}
fn on(backing: &Backing) -> Result<Self> {
Ok(SyncGate {
file: Mutex::new(match backing {
Backing::File { file, .. } => Some(file.try_clone()?),
Backing::Memory { .. } => None,
}),
})
}
fn rebind(&self, backing: &Backing) -> Result<()> {
let mut held = self.file.lock().map_err(|_| poisoned("log sync gate"))?;
*held = match backing {
Backing::File { file, .. } => Some(file.try_clone()?),
Backing::Memory { .. } => None,
};
Ok(())
}
fn barrier(&self, durability: &Durability, target: u64, fsync: bool) -> Result<()> {
if !fsync {
return Ok(());
}
if durability.durable_seq.load(Ordering::SeqCst) >= target {
return Ok(());
}
let held = self.file.lock().map_err(|_| poisoned("log sync gate"))?;
if durability.durable_seq.load(Ordering::SeqCst) >= target {
return Ok(());
}
let Some(file) = held.as_ref() else {
return Ok(());
};
let covers_seq = durability.written_seq.load(Ordering::SeqCst);
let covers_bytes = durability.written.load(Ordering::SeqCst);
let started = std::time::Instant::now();
file.sync_all()?;
durability
.fsync_nanos
.fetch_add(started.elapsed().as_nanos() as u64, Ordering::SeqCst);
durability.syncs.fetch_add(1, Ordering::SeqCst);
durability.durable.fetch_max(covers_bytes, Ordering::SeqCst);
durability
.durable_seq
.fetch_max(covers_seq, Ordering::SeqCst);
Ok(())
}
}
struct Log {
backing: Backing,
schema: SchemaRef,
options: IpcWriteOptions,
generator: IpcDataGenerator,
tracker: DictionaryTracker,
durability: Arc<Durability>,
gate: Arc<SyncGate>,
batches: usize,
fsync: bool,
}
impl std::fmt::Debug for Log {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Log")
.field("backing", &self.backing)
.field("durability", &self.durability)
.finish()
}
}
impl Log {
fn open(backing: Backing, schema: SchemaRef, fsync: bool) -> Result<(Self, Vec<RecordBatch>)> {
let bytes = backing.read_all()?;
let valid = valid_prefix_len(&bytes);
if valid < bytes.len() {
tracing::warn!(
discarded = bytes.len() - valid,
kept = valid,
"the log ends in an incomplete Arrow IPC message; truncating to the last \
complete one so the next append is reachable"
);
backing.truncate_to(valid as u64)?;
backing.sync()?;
}
let gate = Arc::new(if fsync {
SyncGate::on(&backing)?
} else {
SyncGate::detached()
});
let mut log = Log {
backing,
schema,
options: write_options(),
generator: IpcDataGenerator::default(),
tracker: DictionaryTracker::new(false),
durability: Arc::new(Durability::default()),
gate,
batches: 0,
fsync,
};
log.durability.written.store(valid as u64, Ordering::SeqCst);
log.durability.durable.store(valid as u64, Ordering::SeqCst);
let batches = if valid == 0 {
log.write_schema()?;
Vec::new()
} else {
let expected = log.schema.clone();
ipc_guard(&bytes[..valid])?;
let reader = StreamReader::try_new(std::io::Cursor::new(&bytes[..valid]), None)
.map_err(arrow_err)?;
check_schema(reader.schema().as_ref(), expected.as_ref())?;
reader
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(arrow_err)?
};
log.batches = batches.len();
Ok((log, batches))
}
fn rewrite(&mut self, batches: &[RecordBatch]) -> Result<()> {
let bytes = encode_stream(&self.schema, batches, &self.options)?;
self.backing.replace_all(&bytes, self.fsync)?;
self.gate.rebind(&self.backing)?;
let len = bytes.len() as u64;
self.durability.written.store(len, Ordering::SeqCst);
if self.fsync {
self.durability.syncs.fetch_add(1, Ordering::SeqCst);
self.durability.durable.store(len, Ordering::SeqCst);
let staged = self.durability.written_seq.load(Ordering::SeqCst);
self.durability
.durable_seq
.fetch_max(staged, Ordering::SeqCst);
} else {
self.durability.durable.store(0, Ordering::SeqCst);
}
self.generator = IpcDataGenerator::default();
self.tracker = DictionaryTracker::new(false);
self.batches = batches.len();
Ok(())
}
fn write_schema(&mut self) -> Result<()> {
let encoded = self.generator.schema_to_bytes_with_dictionary_tracker(
&self.schema,
&mut self.tracker,
&self.options,
);
let mut framed = Vec::new();
write_message(&mut framed, encoded, &self.options).map_err(arrow_err)?;
self.commit(&framed)
}
fn append(&mut self, batch: &RecordBatch) -> Result<u64> {
let (dictionaries, message) = self
.generator
.encode(
batch,
&mut self.tracker,
&self.options,
&mut IpcWriteContext::default(),
)
.map_err(arrow_err)?;
let mut framed = Vec::new();
for dictionary in dictionaries {
write_message(&mut framed, dictionary, &self.options).map_err(arrow_err)?;
}
write_message(&mut framed, message, &self.options).map_err(arrow_err)?;
let sequence = self.stage(&framed)?;
self.batches += 1;
Ok(sequence)
}
fn stage(&self, framed: &[u8]) -> Result<u64> {
self.backing.append(framed)?;
self.durability
.written
.fetch_add(framed.len() as u64, Ordering::SeqCst);
Ok(self.durability.written_seq.fetch_add(1, Ordering::SeqCst) + 1)
}
fn barrier(&self, sequence: u64) -> Result<()> {
self.gate.barrier(&self.durability, sequence, self.fsync)
}
fn commit(&self, framed: &[u8]) -> Result<()> {
let sequence = self.stage(framed)?;
self.barrier(sequence)
}
}
#[derive(Debug)]
struct LockFile {
path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LockOwner {
pid: u32,
started: Option<u64>,
}
impl LockOwner {
fn parse(text: &str) -> Option<Self> {
let mut fields = text.split_whitespace();
let pid = fields.next()?.parse().ok()?;
let started = fields.next().and_then(|f| f.parse().ok());
Some(LockOwner { pid, started })
}
fn line(&self) -> String {
match self.started {
Some(started) => format!("{} {}\n", self.pid, started),
None => format!("{}\n", self.pid),
}
}
fn this_process() -> Self {
let pid = std::process::id();
LockOwner {
pid,
started: match proc_start_time(pid) {
ProcStartTime::Started(ticks) => Some(ticks),
_ => None,
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProcStartTime {
Started(u64),
NoSuchProcess,
Unknown,
}
fn proc_start_time(pid: u32) -> ProcStartTime {
if !Path::new("/proc/self/stat").exists() {
return ProcStartTime::Unknown;
}
let stat = match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
Ok(stat) => stat,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return ProcStartTime::NoSuchProcess
}
Err(_) => return ProcStartTime::Unknown,
};
let Some((_, fields)) = stat.rsplit_once(')') else {
return ProcStartTime::Unknown;
};
if fields.split_whitespace().next() == Some("Z") {
return ProcStartTime::NoSuchProcess;
}
match fields
.split_whitespace()
.nth(19)
.and_then(|ticks| ticks.parse().ok())
{
Some(ticks) => ProcStartTime::Started(ticks),
None => ProcStartTime::Unknown,
}
}
fn owner_is_gone(owner: &LockOwner) -> bool {
match (proc_start_time(owner.pid), owner.started) {
(ProcStartTime::NoSuchProcess, _) => true,
(ProcStartTime::Started(now), Some(then)) => now != then,
(ProcStartTime::Started(_), None) | (ProcStartTime::Unknown, _) => false,
}
}
impl LockFile {
fn acquire(path: PathBuf) -> Result<Self> {
match Self::create(&path) {
Ok(lock) => Ok(lock),
Err(Error::StoreLocked { .. }) => Self::reclaim_if_stale(path),
Err(err) => Err(err),
}
}
fn create(path: &Path) -> Result<Self> {
match OpenOptions::new().write(true).create_new(true).open(path) {
Ok(mut file) => {
let owner = LockOwner::this_process();
if let Err(err) = write!(file, "{}", owner.line()).and_then(|()| file.sync_all()) {
tracing::warn!(
path = %path.display(),
%err,
"the store lock could not record its owner; a crash will need \
force_unlock"
);
}
Ok(LockFile {
path: path.to_path_buf(),
})
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
Err(Error::StoreLocked {
path: path.to_path_buf(),
})
}
Err(err) => Err(Error::Io(err)),
}
}
fn reclaim_if_stale(path: PathBuf) -> Result<Self> {
let owner = std::fs::read_to_string(&path)
.ok()
.and_then(|text| LockOwner::parse(&text));
let Some(owner) = owner else {
tracing::warn!(
path = %path.display(),
"the store lock names no owner this gunnar can read, so it cannot be \
shown to be stale; refusing"
);
return Err(Error::StoreLocked { path });
};
if !owner_is_gone(&owner) {
return Err(Error::StoreLocked { path });
}
tracing::warn!(
path = %path.display(),
pid = owner.pid,
"the store lock was left by a process that is gone; reclaiming it"
);
match std::fs::remove_file(&path) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(Error::Io(err)),
}
Self::create(&path)
}
}
impl Drop for LockFile {
fn drop(&mut self) {
if let Err(err) = std::fs::remove_file(&self.path) {
tracing::warn!(path = %self.path.display(), %err, "could not release the store lock");
}
}
}
#[derive(Clone, Debug, Default)]
pub struct MemoryLog {
secrets: Arc<Mutex<Vec<u8>>>,
}
struct Inner {
hash_kind: gix_hash::Kind,
actor: String,
policy: CompactionPolicy,
secrets_log: Mutex<Log>,
secrets_state: RwLock<BTreeMap<String, Vec<u8>>>,
_lock: Option<LockFile>,
}
#[derive(Clone)]
pub struct ArrowLogStore {
inner: Arc<Inner>,
}
impl std::fmt::Debug for ArrowLogStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ArrowLogStore")
.field("hash_kind", &self.inner.hash_kind)
.field("lock", &self.inner._lock)
.finish()
}
}
impl ArrowLogStore {
pub fn open(dir: impl AsRef<Path>, hash_kind: gix_hash::Kind) -> Result<Self> {
Self::open_inner(dir.as_ref(), hash_kind, true, CompactionPolicy::default())
}
pub fn open_with_policy(
dir: impl AsRef<Path>,
hash_kind: gix_hash::Kind,
policy: CompactionPolicy,
) -> Result<Self> {
Self::open_inner(dir.as_ref(), hash_kind, true, policy)
}
fn open_inner(
dir: &Path,
hash_kind: gix_hash::Kind,
fsync: bool,
policy: CompactionPolicy,
) -> Result<Self> {
std::fs::create_dir_all(dir)?;
let lock = LockFile::acquire(dir.join(LOCK_FILE))?;
let open_file = |name: &str| -> Result<Backing> {
let path = dir.join(name);
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)?;
Ok(Backing::File { file, path })
};
Self::assemble(
hash_kind,
open_file(SECRETS_LOG)?,
Some(lock),
fsync,
policy,
)
}
pub fn in_memory(hash_kind: gix_hash::Kind) -> Result<(Self, MemoryLog)> {
let log = MemoryLog::default();
let store = Self::open_memory(&log, hash_kind)?;
Ok((store, log))
}
pub fn open_memory(log: &MemoryLog, hash_kind: gix_hash::Kind) -> Result<Self> {
Self::open_memory_with_policy(log, hash_kind, CompactionPolicy::default())
}
pub fn open_memory_with_policy(
log: &MemoryLog,
hash_kind: gix_hash::Kind,
policy: CompactionPolicy,
) -> Result<Self> {
Self::assemble(
hash_kind,
Backing::Memory {
bytes: log.secrets.clone(),
},
None,
true,
policy,
)
}
fn assemble(
hash_kind: gix_hash::Kind,
secrets_backing: Backing,
lock: Option<LockFile>,
fsync: bool,
policy: CompactionPolicy,
) -> Result<Self> {
let (secrets_log, secret_batches) = Log::open(secrets_backing, secret_schema(), fsync)?;
let secrets_batches_on_open = secrets_log.batches;
let store = ArrowLogStore {
inner: Arc::new(Inner {
hash_kind,
actor: DEFAULT_ACTOR.to_owned(),
policy,
secrets_log: Mutex::new(secrets_log),
secrets_state: RwLock::new(replay_secrets(&secret_batches)?),
_lock: lock,
}),
};
if policy.should_compact(secrets_batches_on_open) {
if let Err(err) = store.compact_secrets_now() {
tracing::warn!(%err, "could not fold an oversized secret log at open");
}
}
Ok(store)
}
pub fn force_unlock(dir: impl AsRef<Path>) -> Result<()> {
match std::fs::remove_file(dir.as_ref().join(LOCK_FILE)) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(Error::Io(err)),
}
}
pub fn hash_kind(&self) -> gix_hash::Kind {
self.inner.hash_kind
}
pub fn compaction_policy(&self) -> CompactionPolicy {
self.inner.policy
}
fn compact_secrets_now(&self) -> Result<()> {
let mut log = self
.inner
.secrets_log
.lock()
.map_err(|_| poisoned("secret log"))?;
compact_secrets(&self.inner, &mut log)
}
pub fn secrets(&self) -> ArrowLogSecretStore {
ArrowLogSecretStore {
inner: self.inner.clone(),
}
}
}
fn now_unix_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
pub(crate) fn arrow_err(err: arrow_schema::ArrowError) -> Error {
Error::backend(format!("arrow: {err}"))
}
pub(crate) fn ipc_guard(stream: &[u8]) -> Result<()> {
znippy_common::ipc_guard::check_stream(stream)
.map_err(|e| Error::backend(format!("arrow: refused by the IPC guard: {e}")))
}
pub(crate) fn column<'a, T: 'static>(batch: &'a RecordBatch, name: &str) -> Result<&'a T> {
batch
.column_by_name(name)
.and_then(|c| c.as_any().downcast_ref::<T>())
.ok_or_else(|| {
Error::backend(format!(
"the Arrow log has no usable `{name}` column; the file is not a gunnar log"
))
})
}
fn compact_secrets(inner: &Inner, log: &mut Log) -> Result<()> {
let snapshot = {
let state = inner.secrets_state.read().map_err(|_| poisoned("secret"))?;
let mut keys = StringBuilder::new();
let mut values = BinaryBuilder::new();
let mut actors = StringBuilder::new();
let mut ats = Int64Builder::new();
let at = now_unix_ms();
for (key, value) in state.iter() {
keys.append_value(key);
values.append_value(value);
actors.append_value(COMPACTION_ACTOR);
ats.append_value(at);
}
RecordBatch::try_new(
log.schema.clone(),
vec![
Arc::new(keys.finish()),
Arc::new(values.finish()),
Arc::new(actors.finish()),
Arc::new(ats.finish()),
],
)
.map_err(arrow_err)?
};
log.rewrite(&[snapshot])
}
#[derive(Clone)]
pub struct ArrowLogSecretStore {
inner: Arc<Inner>,
}
impl std::fmt::Debug for ArrowLogSecretStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("ArrowLogSecretStore")
}
}
fn build_secret_batch(
schema: &SchemaRef,
actor: &str,
at: i64,
key: &str,
value: Option<&[u8]>,
) -> Result<RecordBatch> {
let mut keys = StringBuilder::new();
let mut values = BinaryBuilder::new();
let mut actors = StringBuilder::new();
let mut ats = Int64Builder::new();
keys.append_value(key);
match value {
Some(bytes) => values.append_value(bytes),
None => values.append_null(),
}
actors.append_value(actor);
ats.append_value(at);
RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(keys.finish()),
Arc::new(values.finish()),
Arc::new(actors.finish()),
Arc::new(ats.finish()),
],
)
.map_err(arrow_err)
}
fn replay_secrets(batches: &[RecordBatch]) -> Result<BTreeMap<String, Vec<u8>>> {
let mut state = BTreeMap::new();
for batch in batches {
let keys = column::<StringArray>(batch, COL_KEY)?;
let values = column::<BinaryArray>(batch, COL_VALUE)?;
for row in 0..batch.num_rows() {
let key = keys.value(row).to_owned();
if values.is_null(row) {
state.remove(&key);
} else {
state.insert(key, values.value(row).to_vec());
}
}
}
Ok(state)
}
impl ArrowLogSecretStore {
fn write(&self, key: &str, value: Option<&[u8]>) -> Result<()> {
let mut log = self
.inner
.secrets_log
.lock()
.map_err(|_| poisoned("secret log"))?;
self.write_locked(&mut log, key, value)
}
fn write_locked(&self, log: &mut Log, key: &str, value: Option<&[u8]>) -> Result<()> {
let batch = build_secret_batch(&log.schema, &self.inner.actor, now_unix_ms(), key, value)?;
let sequence = log.append(&batch)?;
log.barrier(sequence)?;
let superseded = {
let mut state = self
.inner
.secrets_state
.write()
.map_err(|_| poisoned("secret"))?;
match value {
Some(bytes) => state.insert(key.to_owned(), bytes.to_vec()).is_some(),
None => state.remove(key).is_some(),
}
};
if superseded {
compact_secrets(&self.inner, log).map_err(|err| {
Error::backend(format!(
"the new value for the secret {key} was stored, but the PREVIOUS value \
could not be compacted out of the log and is still on disk: {err}"
))
})?;
} else if self.inner.policy.should_compact(log.batches) {
if let Err(err) = compact_secrets(&self.inner, log) {
tracing::warn!(%err, "could not compact the secret log; it keeps growing");
}
}
Ok(())
}
fn current(&self, key: &str) -> Result<Option<Vec<u8>>> {
Ok(self
.inner
.secrets_state
.read()
.map_err(|_| poisoned("secret"))?
.get(key)
.cloned())
}
}
impl SecretStore for ArrowLogSecretStore {
fn get(&self, key: &str) -> Result<Option<Vec<u8>>> {
self.current(key)
}
fn put(&self, key: &str, value: &[u8]) -> Result<()> {
self.write(key, Some(value))
}
fn delete(&self, key: &str) -> Result<()> {
if self.current(key)?.is_none() {
return Ok(());
}
self.write(key, None)
}
fn get_or_create(&self, key: &str, mk: &mut dyn FnMut() -> Result<Vec<u8>>) -> Result<Vec<u8>> {
let mut log = self
.inner
.secrets_log
.lock()
.map_err(|_| poisoned("secret log"))?;
if let Some(existing) = self.current(key)? {
return Ok(existing);
}
let value = mk()?;
self.write_locked(&mut log, key, Some(&value))?;
Ok(value)
}
}
#[cfg(test)]
mod tests {
use super::*;
use gunnar_store::SecretStoreExt;
const KIND: gix_hash::Kind = gix_hash::Kind::Sha256;
fn dir() -> tempfile::TempDir {
tempfile::tempdir().expect("a temporary directory")
}
#[test]
fn the_in_memory_secret_log_replays_across_a_rebuild() {
let (store, log) = ArrowLogStore::in_memory(KIND).expect("in-memory store");
store.secrets().put("k", b"v").expect("put");
drop(store);
let rebuilt = ArrowLogStore::open_memory(&log, KIND).expect("replay");
assert_eq!(
rebuilt.secrets().get("k").expect("get"),
Some(b"v".to_vec())
);
}
#[test]
fn a_secret_delete_is_a_tombstone_that_survives_a_reopen() {
let dir = dir();
{
let store = ArrowLogStore::open(dir.path(), KIND).expect("open");
store.secrets().put("k", b"v1").expect("put");
store.secrets().delete("k").expect("delete");
}
let store = ArrowLogStore::open(dir.path(), KIND).expect("reopen");
assert!(store.secrets().get("k").expect("get").is_none());
store.secrets().put("k", b"v2").expect("put again");
assert_eq!(store.secrets().get("k").expect("get"), Some(b"v2".to_vec()));
}
#[test]
fn a_rotated_secret_leaves_no_trace_of_the_previous_value_on_disk() {
let dir = dir();
let old = b"OLD-SECRET-VALUE-that-must-not-survive-rotation";
let new = b"NEW-SECRET-VALUE-after-the-rotation";
let store = ArrowLogStore::open(dir.path(), KIND).expect("open");
store.secrets().put("ssh/host_ed25519", old).expect("put");
let contains = |needle: &[u8]| {
let bytes = std::fs::read(dir.path().join(SECRETS_LOG)).expect("read the secret log");
assert!(!bytes.is_empty(), "the secret log is empty");
bytes.windows(needle.len()).any(|w| w == needle)
};
assert!(contains(old));
store
.secrets()
.put("ssh/host_ed25519", new)
.expect("rotate");
assert!(!contains(old), "the previous secret survived rotation");
assert!(contains(new), "the new value is not in the log");
assert_eq!(
store.secrets().get("ssh/host_ed25519").expect("get"),
Some(new.to_vec())
);
store.secrets().delete("ssh/host_ed25519").expect("delete");
assert!(
!contains(new),
"a deleted secret's value is still in the log"
);
}
#[test]
fn compacting_secrets_does_not_retain_superseded_values() {
let dir = dir();
let store = ArrowLogStore::open_with_policy(dir.path(), KIND, CompactionPolicy::new(4, 0))
.expect("open");
for generation in 0..12 {
store
.secrets()
.put(
"host-key",
format!("secret-generation-{generation:02}").as_bytes(),
)
.expect("put");
}
let haystack = String::from_utf8_lossy(
&std::fs::read(dir.path().join(SECRETS_LOG)).expect("read the secret log"),
)
.into_owned();
assert!(!haystack.contains("secret-generation-00"));
assert!(haystack.contains("secret-generation-11"));
assert!(!dir.path().join(REFS_ARCHIVE_DIR).exists());
drop(store);
let reopened =
ArrowLogStore::open_with_policy(dir.path(), KIND, CompactionPolicy::new(4, 0))
.expect("reopen");
assert_eq!(
reopened.secrets().get("host-key").expect("get"),
Some(b"secret-generation-11".to_vec())
);
}
#[test]
fn get_or_create_does_not_remint_across_a_reopen() {
let dir = dir();
let first = {
let store = ArrowLogStore::open(dir.path(), KIND).expect("open");
store
.secrets()
.get_or_create_with(gunnar_store::SSH_HOST_KEY, || Ok(b"host-key-1".to_vec()))
.expect("mint")
};
let store = ArrowLogStore::open(dir.path(), KIND).expect("reopen");
let second = store
.secrets()
.get_or_create_with(gunnar_store::SSH_HOST_KEY, || {
panic!("the host key was re-minted after a restart")
})
.expect("read back");
assert_eq!(first, second);
}
#[test]
fn a_secret_is_durable_before_the_write_returns() {
let dir = dir();
let store = ArrowLogStore::open(dir.path(), KIND).expect("open");
store
.secrets()
.put(gunnar_store::SSH_HOST_KEY, b"a-host-key")
.expect("write a secret");
let on_disk = std::fs::metadata(dir.path().join(SECRETS_LOG))
.expect("stat")
.len();
let durability = &store
.inner
.secrets_log
.lock()
.expect("the writer")
.durability;
let durable = durability.durable.load(Ordering::SeqCst);
let written = durability.written.load(Ordering::SeqCst);
assert_eq!(written, on_disk);
assert_eq!(durable, written);
assert!(durability.syncs.load(Ordering::SeqCst) >= 2);
}
}