Merge feat/v0.21-kei-store-s3 — real S3 backend (CHANGELOG conflict resolved)
Kept both Added blocks: v0.21 SSoT/Scope (from earlier merge) and v0.21 kei-store S3. Both are part of the v0.21 ship.
This commit is contained in:
commit
969ddf34cd
12 changed files with 1679 additions and 50 deletions
|
|
@ -25,6 +25,14 @@ _primitives/_rust/target/release/kei-changelog \
|
|||
- Marker file relocated from `~/.claude/keisei-attached.toml` to `~/.keisei/attached.toml`. `~/.claude/` is Claude-Code-specific territory and should not host cross-adapter keisei state. `config::read()` performs a one-shot migration the first time it runs under v0.21: if the legacy file exists and the new location is empty, the marker moves over (new file written, legacy file deleted) and a stderr notice is emitted.
|
||||
- `Scope` enum (`user` / `project`) on the `ClientAdapter` trait. Adapters declare `supported_scopes()`; `config_path(scope)`, `attach(brain, scope)`, `detach(brain_name, scope)` are scope-aware. Claude Code and Cursor support both scopes; Continue and Zed are user-only. `keisei attach` gains `--scope=<user|project>` (default `user`); `keisei mount` stays host-wide (`Scope::User` fan-out by design).
|
||||
- Marker schema v3: each `[[attachments]]` entry carries `scope = "user" | "project"`. Pre-v0.21 markers without the field default to `Scope::User` silently. New error variant `Error::ScopeUnsupported { client, scope, supported }` fires when a caller asks for a scope the adapter doesn't advertise.
|
||||
- **primitives (v0.21 — `kei-store` real S3 backend):**
|
||||
- `S3CloudStore` — functional S3 / R2 / MinIO / Wasabi backend via `aws-sdk-s3` v1. GetObject / PutObject / ListObjectsV2 (paginated) / DeleteObject wired behind the existing `MemoryStore` trait (sync-over-async via a single-thread tokio runtime). Enables `keisei attach s3://my-bucket/brain/` as a real cloud-mount path, not just a local stub.
|
||||
- Opt-in feature flag `s3` on the `kei-store` crate — off by default so users who don't need cloud pay zero binary weight. Enabling adds tokio + hyper + rustls + aws-sdk-s3 (~5 MB release binary growth).
|
||||
- AWS default credential chain honoured (env vars → `~/.aws/credentials` → IMDS). No new credential format; RULE 0.8 secrets-single-source unchanged.
|
||||
- Endpoint override for non-AWS S3-compat providers via `KEI_STORE_S3_ENDPOINT` env var (runtime) or `s3.endpoint` in `store-config.toml` (persistent). Path-style addressing auto-enabled when a custom endpoint is set (MinIO / some R2 configs).
|
||||
- "Branch" semantics: S3 has no native branching, so a branch is modelled as a key prefix (`<branch>/<path>`). `branch()` sets the active prefix in-memory; default `main`.
|
||||
- Factory auto-routes: `backend = "s3"` + feature `s3` + `s3.bucket` set → real cloud; otherwise falls back to the v0.14 local-manifest stub (still behind `KEI_STORE_ALLOW_S3_STUB=1`).
|
||||
- Path-traversal guard parity with `FilesystemStore`: absolute and `..`-component paths rejected before keys are spliced.
|
||||
- **primitives (v0.20 — brain schema v2 + per-client hint):**
|
||||
- Brain schema v2 with per-platform `mcp_server` dispatch — a single brain directory can now host binaries for darwin-arm64/darwin-x64/linux-x64/linux-arm64/windows-x64 and `keisei attach` picks the right one automatically. Schema v1 (single string) still accepted for backward-compat.
|
||||
- `ClientAdapter::post_attach_hint()` — per-client reload instruction, no more hardcoded Claude-Code string in the orchestrator.
|
||||
|
|
|
|||
|
|
@ -217,7 +217,7 @@ Use cases:
|
|||
|
||||
- **Laptop travel.** Brain lives on USB / iCloud Drive. Plug in at home → `keisei mount /Volumes/MyBrain` attaches to Claude Code + Cursor simultaneously. Unplug → `keisei detach` clears everything.
|
||||
- **Team shared persona library.** Commit a brain repo to your private Forgejo/GitHub. Every developer clones it, runs `keisei mount ./team-brain`, same 30-agent persona library active in their Claude Code.
|
||||
- **Cloud brain.** Point `keisei attach s3://my-bucket/brain/` at an S3-backed brain synced via `kei-store` (v0.20 — S3 backend currently MVP stub). Memory follows you to any machine with network.
|
||||
- **Cloud brain.** Point `keisei attach s3://my-bucket/brain/` at an S3-backed brain synced via `kei-store` (v0.21 — real S3 / R2 / MinIO backend behind the `s3` feature flag). Memory follows you to any machine with network.
|
||||
- **Experimental personas in isolation.** Spin up a test brain via `cp -r ~/production-brain ~/experimental-brain`, `keisei attach ~/experimental-brain`. Iterate without touching production state.
|
||||
|
||||
Security hardening (v0.19.0):
|
||||
|
|
@ -395,7 +395,7 @@ Two output modes, chosen once in `/sleep-setup` Phase 3b:
|
|||
| Forgejo self-hosted | production | Same wire protocol as GitHub |
|
||||
| Gitea self-hosted | production | Same wire protocol |
|
||||
| Filesystem only | production | Local `.git`; no push; fastest |
|
||||
| S3 / R2 / MinIO | stub — local only until v0.15 | Manifest-based local cache ONLY; no upload to S3/R2/MinIO yet. Requires `KEI_STORE_ALLOW_S3_STUB=1` (explicit opt-in so you don't accidentally believe your data is in the cloud). `aws-sdk-s3` integration planned for v0.15. |
|
||||
| S3 / R2 / MinIO | production (v0.21, behind `s3` feature) | Real GetObject / PutObject / ListObjectsV2 via `aws-sdk-s3`. Build with `cargo build -p kei-store --features s3` and set `[s3] bucket = "..."` in `store-config.toml`. AWS default credential chain (env vars → `~/.aws/credentials` → IMDS). Custom endpoint for R2 / MinIO / Wasabi via `KEI_STORE_S3_ENDPOINT` env or `s3.endpoint` TOML field. Binary grows ~5 MB when the feature is on. Omit the feature OR omit `s3.bucket` to fall back to the v0.14 local-manifest stub (still gated by `KEI_STORE_ALLOW_S3_STUB=1`). |
|
||||
|
||||
Requires the new `kei-conflict-scan`, `kei-refactor-engine`, `kei-graph-check`, and `kei-store` primitives (shipped in the `dev` and `full` profiles). Governed by the Phase C extension of RULE 0.15 in `~/.claude/rules/sleep-layer.md`.
|
||||
|
||||
|
|
@ -417,7 +417,7 @@ Requires the new `kei-conflict-scan`, `kei-refactor-engine`, `kei-graph-check`,
|
|||
| `kei-conflict-scan` | v0.13.0 — deep-sleep conflict scanner across rules/hooks/blocks/orphans/CP violations |
|
||||
| `kei-refactor-engine` | v0.13.0 — consumes `kei-conflict-scan` JSON; emits plan markdown + auto-resolve review markdown (NOT a unified diff; v0.14.1 retraction) |
|
||||
| `kei-graph-check` | v0.13.0 — post-refactor wikilink + handoff + block-ref resolver gate |
|
||||
| `kei-store` | v0.13.0 — memory-repo backend abstraction (GitHub / Forgejo / Gitea / Filesystem / S3) |
|
||||
| `kei-store` | v0.21.0 — memory-repo backend abstraction (GitHub / Forgejo / Gitea / Filesystem / S3). S3 backend is a real `aws-sdk-s3` client when built with `--features s3` (supports AWS / R2 / MinIO / Wasabi); otherwise falls back to the v0.14 local-manifest stub. |
|
||||
| `keisei` | v0.19.0 — exobrain multi-client CLI — `attach` / `mount` / `detach` / `status` / `list-adapters`. Mounts a portable brain into every detected AI client in one shot. Supported clients: Claude Code, Cursor, Continue, Zed. `mount` fan-outs to all detected adapters; `detach` round-trips cleanly and preserves the user's other MCP/context-server entries. Marker SSoT is `~/.claude/keisei-attached.toml` (schema v2 — list of attachments; v1 auto-migrated). |
|
||||
|
||||
## Primitives (shell)
|
||||
|
|
|
|||
|
|
@ -181,8 +181,8 @@ desc = "Post-refactor graph-integrity gate — wikilinks + handoffs + block refs
|
|||
[primitive.kei-store]
|
||||
kind = "rust"
|
||||
crate = "kei-store"
|
||||
deps = ["git2 (vendored libgit2)"]
|
||||
desc = "Memory-repo backend abstraction — GitHub / Forgejo / Gitea / Filesystem / S3 (S3 = MVP stub)"
|
||||
deps = ["git2 (vendored libgit2)", "aws-sdk-s3 + tokio + rustls (optional, behind `s3` feature)"]
|
||||
desc = "Memory-repo backend abstraction — GitHub / Forgejo / Gitea / Filesystem / S3 (real S3/R2/MinIO via aws-sdk-s3 when built with `--features s3`; local-manifest stub otherwise)"
|
||||
|
||||
# --- v0.14 LBM port (10) ---------------------------------------------------
|
||||
|
||||
|
|
|
|||
1183
_primitives/_rust/Cargo.lock
generated
1183
_primitives/_rust/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -3,7 +3,7 @@ name = "kei-store"
|
|||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Memory-repo backend abstraction — GitHub/Forgejo/Gitea/Filesystem/S3 (v0.13.0)"
|
||||
description = "Memory-repo backend abstraction — GitHub/Forgejo/Gitea/Filesystem/S3 (v0.21.0)"
|
||||
|
||||
[[bin]]
|
||||
name = "kei-store"
|
||||
|
|
@ -12,6 +12,13 @@ path = "src/main.rs"
|
|||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
# Default: no cloud deps. S3 backend behaves as the v0.14 local-manifest stub
|
||||
# (gated by KEI_STORE_ALLOW_S3_STUB=1). Users who actually need real S3 / R2 /
|
||||
# MinIO push opt into the heavier AWS SDK stack by enabling this feature.
|
||||
default = []
|
||||
s3 = ["dep:aws-config", "dep:aws-sdk-s3", "dep:aws-credential-types", "dep:tokio"]
|
||||
|
||||
[dependencies]
|
||||
clap = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
|
@ -20,5 +27,11 @@ anyhow = "1"
|
|||
toml = "0.8"
|
||||
git2 = { version = "0.19", default-features = false }
|
||||
|
||||
# v0.21 — optional cloud stack behind `s3` feature.
|
||||
aws-config = { version = "1", default-features = false, features = ["behavior-version-latest", "rustls", "rt-tokio"], optional = true }
|
||||
aws-sdk-s3 = { version = "1", default-features = false, features = ["behavior-version-latest", "rustls", "rt-tokio"], optional = true }
|
||||
aws-credential-types = { version = "1", optional = true }
|
||||
tokio = { version = "1", features = ["rt", "macros"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
//! Factory — construct a `Box<dyn MemoryStore>` from a Config.
|
||||
//!
|
||||
//! v0.14.1: the S3 backend is gated behind `KEI_STORE_ALLOW_S3_STUB=1`
|
||||
//! because it does NOT push to S3 yet — it's a local-manifest stub.
|
||||
//! Previous behaviour silently stored data locally, confusing users who
|
||||
//! thought their traces were uploaded.
|
||||
//! because the default build has no real S3 push — it's a local-manifest
|
||||
//! stub. Previous behaviour silently stored data locally, confusing users
|
||||
//! who thought their traces were uploaded.
|
||||
//!
|
||||
//! v0.21.0: when the crate is built with `--features s3` AND
|
||||
//! `s3.bucket` is configured, the real `S3CloudStore` is used (no
|
||||
//! KEI_STORE_ALLOW_S3_STUB gate needed — data really is uploaded).
|
||||
//! The stub path remains available for users who don't want the AWS SDK
|
||||
//! in their binary: omit `s3.bucket` and set the stub opt-in env.
|
||||
|
||||
use crate::config::{expand_tilde, Config};
|
||||
use crate::{filesystem::FilesystemStore, forgejo::ForgejoStore, gitea::GiteaStore,
|
||||
|
|
@ -29,12 +35,30 @@ pub fn build_store(cfg: &Config) -> Result<Box<dyn MemoryStore>> {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "s3")]
|
||||
fn build_s3(cfg: &Config) -> Result<Box<dyn MemoryStore>> {
|
||||
// Cloud path: real S3 round-trips when bucket is configured.
|
||||
if cfg.s3.bucket.is_some() {
|
||||
return Ok(Box::new(
|
||||
crate::s3_cloud::S3CloudStore::new(cfg.s3.clone())?,
|
||||
));
|
||||
}
|
||||
// Fallback: local stub (legacy behaviour, requires opt-in).
|
||||
build_s3_stub(cfg)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "s3"))]
|
||||
fn build_s3(cfg: &Config) -> Result<Box<dyn MemoryStore>> {
|
||||
build_s3_stub(cfg)
|
||||
}
|
||||
|
||||
fn build_s3_stub(cfg: &Config) -> Result<Box<dyn MemoryStore>> {
|
||||
if std::env::var("KEI_STORE_ALLOW_S3_STUB").is_err() {
|
||||
bail!(
|
||||
"S3 backend is a local-only MVP stub (no upload to S3/R2/MinIO yet). \
|
||||
Set KEI_STORE_ALLOW_S3_STUB=1 to proceed; data will be stored in the \
|
||||
configured cache_path only. Production S3 support is planned for v0.15."
|
||||
configured cache_path only. For real S3 push, build with \
|
||||
`--features s3` AND set s3.bucket in config."
|
||||
);
|
||||
}
|
||||
eprintln!(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
//! Trait `MemoryStore` + 5 implementations:
|
||||
//! - `GitHubStore`, `ForgejoStore`, `GiteaStore` — git-over-SSH/HTTPS
|
||||
//! - `FilesystemStore` — local `.git` only; never pushes
|
||||
//! - `S3Store` — object-storage with manifest.json (MVP stub)
|
||||
//! - `S3Store` — object-storage with manifest.json (MVP local stub)
|
||||
//! - `S3CloudStore` — real S3 / R2 / MinIO via `aws-sdk-s3`
|
||||
//! (behind `s3` feature; v0.21+)
|
||||
//!
|
||||
//! Config loaded from `~/.claude/agents/_primitives/store-config.toml`
|
||||
//! by default; overridable via `--config`.
|
||||
|
|
@ -18,6 +20,8 @@ pub mod forgejo;
|
|||
pub mod gitea;
|
||||
pub mod github;
|
||||
pub mod s3;
|
||||
#[cfg(feature = "s3")]
|
||||
pub mod s3_cloud;
|
||||
pub mod store_trait;
|
||||
|
||||
pub use config::Config;
|
||||
|
|
|
|||
81
_primitives/_rust/kei-store/src/s3_cloud/client.rs
Normal file
81
_primitives/_rust/kei-store/src/s3_cloud/client.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
//! aws-sdk-s3 client builder for the S3 cloud backend.
|
||||
//!
|
||||
//! Wraps `aws_config::defaults()` + optional endpoint override for
|
||||
//! R2 / MinIO / Wasabi / any S3-compat provider. Credential chain is the
|
||||
//! AWS default — env vars, `~/.aws/credentials`, IMDS — we do NOT invent
|
||||
//! a new credential format (RULE 0.8 secrets-single-source honoured).
|
||||
|
||||
use crate::config::S3Cfg;
|
||||
use anyhow::Result;
|
||||
use aws_sdk_s3::Client;
|
||||
|
||||
/// Resolve the effective endpoint URL:
|
||||
/// 1. `KEI_STORE_S3_ENDPOINT` env var (runtime override for tests / R2)
|
||||
/// 2. `cfg.endpoint` (TOML config)
|
||||
/// 3. None → aws-sdk-s3 default (real AWS)
|
||||
pub fn effective_endpoint(cfg: &S3Cfg) -> Option<String> {
|
||||
if let Ok(url) = std::env::var("KEI_STORE_S3_ENDPOINT") {
|
||||
if !url.is_empty() {
|
||||
return Some(url);
|
||||
}
|
||||
}
|
||||
cfg.endpoint.clone()
|
||||
}
|
||||
|
||||
/// Build the aws-sdk-s3 client with optional endpoint + region overrides.
|
||||
pub async fn build_client(cfg: &S3Cfg) -> Result<Client> {
|
||||
let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
|
||||
if let Some(region) = cfg.region.clone() {
|
||||
loader = loader.region(aws_config::Region::new(region));
|
||||
}
|
||||
let shared = loader.load().await;
|
||||
let mut s3_builder = aws_sdk_s3::config::Builder::from(&shared);
|
||||
if let Some(endpoint) = effective_endpoint(cfg) {
|
||||
// Path-style is the safest default for non-AWS endpoints (MinIO,
|
||||
// some R2 configs). AWS itself also accepts path-style.
|
||||
s3_builder = s3_builder.endpoint_url(endpoint).force_path_style(true);
|
||||
}
|
||||
Ok(Client::from_conf(s3_builder.build()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg_with_endpoint(endpoint: &str) -> S3Cfg {
|
||||
S3Cfg {
|
||||
endpoint: Some(endpoint.to_string()),
|
||||
bucket: Some("test-bucket".to_string()),
|
||||
region: Some("us-east-1".to_string()),
|
||||
access_key_env: None,
|
||||
secret_key_env: None,
|
||||
cache_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_endpoint_env_overrides_cfg() {
|
||||
std::env::set_var("KEI_STORE_S3_ENDPOINT", "http://127.0.0.1:9000");
|
||||
let cfg = cfg_with_endpoint("http://other:8080");
|
||||
let got = effective_endpoint(&cfg);
|
||||
std::env::remove_var("KEI_STORE_S3_ENDPOINT");
|
||||
assert_eq!(got.as_deref(), Some("http://127.0.0.1:9000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_endpoint_cfg_when_no_env() {
|
||||
std::env::remove_var("KEI_STORE_S3_ENDPOINT");
|
||||
let cfg = cfg_with_endpoint("http://127.0.0.1:9999");
|
||||
assert_eq!(
|
||||
effective_endpoint(&cfg).as_deref(),
|
||||
Some("http://127.0.0.1:9999")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_endpoint_none_when_no_env_no_cfg() {
|
||||
std::env::remove_var("KEI_STORE_S3_ENDPOINT");
|
||||
let cfg = S3Cfg::default();
|
||||
assert_eq!(effective_endpoint(&cfg), None);
|
||||
}
|
||||
}
|
||||
60
_primitives/_rust/kei-store/src/s3_cloud/keys.rs
Normal file
60
_primitives/_rust/kei-store/src/s3_cloud/keys.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
//! Key-path helpers for the S3 cloud backend.
|
||||
//!
|
||||
//! `validate_rel` is the CVE-class guard: rejects absolute paths and
|
||||
//! `..` components before they are spliced into an S3 key. Same pattern as
|
||||
//! `filesystem::safe_join` — keeps the per-branch prefix unescapable.
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
/// Reject absolute paths and any `..` component in the caller's rel path.
|
||||
pub fn validate_rel(rel: &str) -> Result<()> {
|
||||
if rel.starts_with('/') {
|
||||
bail!("path traversal rejected: absolute path {:?}", rel);
|
||||
}
|
||||
for part in rel.split('/') {
|
||||
if part == ".." {
|
||||
bail!("path traversal rejected: parent-dir component in {:?}", rel);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tiny DJB2 — deterministic, avoids pulling sha2 just for manifest naming.
|
||||
pub fn short_hash(s: &str) -> String {
|
||||
let mut h: u64 = 5381;
|
||||
for b in s.bytes() {
|
||||
h = h.wrapping_mul(33).wrapping_add(b as u64);
|
||||
}
|
||||
format!("{:x}", h)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validate_rel_rejects_absolute() {
|
||||
let err = validate_rel("/etc/passwd").unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("absolute"), "unexpected err: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rel_rejects_parent() {
|
||||
let err = validate_rel("a/../b").unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("parent-dir"), "unexpected err: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rel_accepts_normal() {
|
||||
validate_rel("traces/session.jsonl").unwrap();
|
||||
validate_rel("a/b/c.txt").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_hash_deterministic() {
|
||||
assert_eq!(short_hash("abc"), short_hash("abc"));
|
||||
assert_ne!(short_hash("abc"), short_hash("abd"));
|
||||
}
|
||||
}
|
||||
190
_primitives/_rust/kei-store/src/s3_cloud/mod.rs
Normal file
190
_primitives/_rust/kei-store/src/s3_cloud/mod.rs
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
//! S3CloudStore — real object-storage backend via `aws-sdk-s3`.
|
||||
//!
|
||||
//! Behind `#[cfg(feature = "s3")]`; ~5 MB release binary growth.
|
||||
//! Credentials via AWS default chain (env / ~/.aws / IMDS).
|
||||
//! Endpoint override for R2 / MinIO / Wasabi: `KEI_STORE_S3_ENDPOINT`
|
||||
//! env var OR `s3.endpoint` in TOML.
|
||||
//! "Branch" = key prefix (`<branch>/<path>`); default `main`.
|
||||
//! Sync `MemoryStore` trait bridged over async SDK via a single
|
||||
//! current-thread tokio runtime (`block_on` per call).
|
||||
|
||||
mod client;
|
||||
mod keys;
|
||||
|
||||
use crate::config::S3Cfg;
|
||||
use crate::store_trait::MemoryStore;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::Client;
|
||||
use std::sync::Mutex;
|
||||
use tokio::runtime::{Builder, Runtime};
|
||||
|
||||
const DEFAULT_BRANCH: &str = "main";
|
||||
|
||||
pub struct S3CloudStore {
|
||||
client: Client,
|
||||
bucket: String,
|
||||
rt: Runtime,
|
||||
branch: Mutex<String>,
|
||||
}
|
||||
|
||||
impl S3CloudStore {
|
||||
/// Build a cloud-S3 backend. `bucket` MUST be configured.
|
||||
pub fn new(cfg: S3Cfg) -> Result<Self> {
|
||||
let bucket = cfg
|
||||
.bucket
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("s3 backend requires s3.bucket in config"))?;
|
||||
let rt = Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.context("init tokio runtime")?;
|
||||
let client = rt.block_on(client::build_client(&cfg))?;
|
||||
Ok(Self {
|
||||
client,
|
||||
bucket,
|
||||
rt,
|
||||
branch: Mutex::new(DEFAULT_BRANCH.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
fn current_branch(&self) -> String {
|
||||
self.branch
|
||||
.lock()
|
||||
.map(|g| g.clone())
|
||||
.unwrap_or_else(|poison| poison.into_inner().clone())
|
||||
}
|
||||
|
||||
fn key(&self, rel: &str) -> Result<String> {
|
||||
keys::validate_rel(rel)?;
|
||||
Ok(format!("{}/{}", self.current_branch(), rel))
|
||||
}
|
||||
|
||||
async fn get(&self, key: &str) -> Result<Vec<u8>> {
|
||||
let resp = self
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("s3 get_object {key}"))?;
|
||||
let body = resp
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.with_context(|| format!("s3 read body {key}"))?;
|
||||
Ok(body.into_bytes().to_vec())
|
||||
}
|
||||
|
||||
async fn put(&self, key: &str, bytes: Vec<u8>) -> Result<()> {
|
||||
self.client
|
||||
.put_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from(bytes))
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("s3 put_object {key}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_prefix(&self, prefix: &str) -> Result<Vec<String>> {
|
||||
let mut out = Vec::new();
|
||||
let mut token: Option<String> = None;
|
||||
loop {
|
||||
let mut req = self
|
||||
.client
|
||||
.list_objects_v2()
|
||||
.bucket(&self.bucket)
|
||||
.prefix(prefix)
|
||||
.delimiter("/");
|
||||
if let Some(t) = token.as_ref() {
|
||||
req = req.continuation_token(t);
|
||||
}
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("s3 list {prefix}"))?;
|
||||
for obj in resp.contents() {
|
||||
if let Some(k) = obj.key() {
|
||||
if let Some(name) = k.strip_prefix(prefix) {
|
||||
if !name.is_empty() {
|
||||
out.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if resp.is_truncated().unwrap_or(false) {
|
||||
token = resp.next_continuation_token().map(|s| s.to_string());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
impl MemoryStore for S3CloudStore {
|
||||
fn read(&self, path: &str) -> Result<Vec<u8>> {
|
||||
let key = self.key(path)?;
|
||||
self.rt.block_on(self.get(&key))
|
||||
}
|
||||
|
||||
fn write(&self, path: &str, bytes: &[u8]) -> Result<()> {
|
||||
let key = self.key(path)?;
|
||||
self.rt.block_on(self.put(&key, bytes.to_vec()))
|
||||
}
|
||||
|
||||
fn list(&self, dir: &str) -> Result<Vec<String>> {
|
||||
let raw = self.key(dir)?;
|
||||
let prefix = if raw.ends_with('/') {
|
||||
raw
|
||||
} else {
|
||||
format!("{raw}/")
|
||||
};
|
||||
self.rt.block_on(self.list_prefix(&prefix))
|
||||
}
|
||||
|
||||
fn branch(&self, name: &str) -> Result<()> {
|
||||
keys::validate_rel(name)?;
|
||||
let mut g = self
|
||||
.branch
|
||||
.lock()
|
||||
.map_err(|_| anyhow!("branch lock poisoned"))?;
|
||||
*g = name.to_string();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn commit(&self, message: &str) -> Result<String> {
|
||||
// Object stores have no native commit — write a manifest blob naming
|
||||
// every key under the current branch and return a cheap hash.
|
||||
let entries = self.list("")?;
|
||||
let manifest = serde_json::json!({
|
||||
"message": message,
|
||||
"branch": self.current_branch(),
|
||||
"entries": entries,
|
||||
})
|
||||
.to_string();
|
||||
let hash = keys::short_hash(&manifest);
|
||||
self.write(&format!("manifest-{hash}.json"), manifest.as_bytes())?;
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
fn push(&self, _branch: &str) -> Result<()> {
|
||||
// Every write() already persists to S3 — no staging to flush.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pull(&self, _branch: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn backend_name(&self) -> &'static str {
|
||||
"s3-cloud"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
63
_primitives/_rust/kei-store/src/s3_cloud/tests.rs
Normal file
63
_primitives/_rust/kei-store/src/s3_cloud/tests.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
//! Unit tests for S3CloudStore — no network, mock-endpoint only.
|
||||
//!
|
||||
//! These tests verify builder correctness + path-safety guards. They do
|
||||
//! NOT exercise real S3 round-trips (that would require live AWS/MinIO
|
||||
//! and would fail in CI without credentials). See `tests/s3_smoke.rs`
|
||||
//! for the cross-crate smoke integration.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn cfg(endpoint: &str) -> S3Cfg {
|
||||
S3Cfg {
|
||||
endpoint: Some(endpoint.to_string()),
|
||||
bucket: Some("test-bucket".to_string()),
|
||||
region: Some("us-east-1".to_string()),
|
||||
access_key_env: None,
|
||||
secret_key_env: None,
|
||||
cache_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_missing_bucket() {
|
||||
let c = S3Cfg {
|
||||
endpoint: Some("http://127.0.0.1:9999".to_string()),
|
||||
region: Some("us-east-1".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let err = S3CloudStore::new(c)
|
||||
.err()
|
||||
.expect("missing bucket should error");
|
||||
assert!(format!("{err:#}").contains("bucket"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_builds_with_mock_endpoint() {
|
||||
let store = S3CloudStore::new(cfg("http://127.0.0.1:9999")).unwrap();
|
||||
assert_eq!(store.backend_name(), "s3-cloud");
|
||||
assert_eq!(store.current_branch(), "main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_updates_prefix() {
|
||||
let store = S3CloudStore::new(cfg("http://127.0.0.1:9999")).unwrap();
|
||||
store.branch("feat/foo").unwrap();
|
||||
assert_eq!(
|
||||
store.key("traces/a.jsonl").unwrap(),
|
||||
"feat/foo/traces/a.jsonl"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_rejects_parent() {
|
||||
let store = S3CloudStore::new(cfg("http://127.0.0.1:9999")).unwrap();
|
||||
let err = store.branch("../escape").unwrap_err();
|
||||
assert!(format!("{err:#}").contains("parent-dir"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_rejects_absolute() {
|
||||
let store = S3CloudStore::new(cfg("http://127.0.0.1:9999")).unwrap();
|
||||
let err = store.key("/etc/passwd").unwrap_err();
|
||||
assert!(format!("{err:#}").contains("absolute"));
|
||||
}
|
||||
81
_primitives/_rust/kei-store/tests/s3_smoke.rs
Normal file
81
_primitives/_rust/kei-store/tests/s3_smoke.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
//! Smoke tests for the S3 cloud backend (behind `s3` feature).
|
||||
//!
|
||||
//! These tests never hit real AWS. They verify:
|
||||
//! - the `S3CloudStore` builder accepts a mock endpoint without panic
|
||||
//! - the library re-exports `s3_cloud` when the feature is enabled
|
||||
//! - path-safety guards reject traversal attempts
|
||||
//!
|
||||
//! Run with: `cargo test -p kei-store --features s3 --test s3_smoke`.
|
||||
//! Without the feature, this file compiles to an empty crate — harmless.
|
||||
|
||||
#![cfg(feature = "s3")]
|
||||
|
||||
use kei_store::config::S3Cfg;
|
||||
use kei_store::s3_cloud::S3CloudStore;
|
||||
use kei_store::MemoryStore;
|
||||
|
||||
fn mock_cfg(endpoint: &str) -> S3Cfg {
|
||||
S3Cfg {
|
||||
endpoint: Some(endpoint.to_string()),
|
||||
bucket: Some("test-bucket".to_string()),
|
||||
region: Some("us-east-1".to_string()),
|
||||
access_key_env: None,
|
||||
secret_key_env: None,
|
||||
cache_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_accepts_mock_endpoint() {
|
||||
let store = S3CloudStore::new(mock_cfg("http://127.0.0.1:9999"))
|
||||
.expect("builder must not require network");
|
||||
assert_eq!(store.backend_name(), "s3-cloud");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_rejects_missing_bucket() {
|
||||
let cfg = S3Cfg {
|
||||
endpoint: Some("http://127.0.0.1:9999".to_string()),
|
||||
region: Some("us-east-1".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let err = S3CloudStore::new(cfg)
|
||||
.err()
|
||||
.expect("missing bucket should error");
|
||||
assert!(format!("{err:#}").contains("bucket"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_switches_prefix() {
|
||||
let store = S3CloudStore::new(mock_cfg("http://127.0.0.1:9999")).unwrap();
|
||||
store.branch("agent/foo").unwrap();
|
||||
// No network IO — just verify branch() does not error and backend_name
|
||||
// stays stable.
|
||||
assert_eq!(store.backend_name(), "s3-cloud");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_fails_gracefully_on_unreachable_endpoint() {
|
||||
// Point at a closed port — real put_object must error, NOT panic.
|
||||
let store = S3CloudStore::new(mock_cfg("http://127.0.0.1:9")).unwrap();
|
||||
let err = store.write("traces/x.jsonl", b"hello").unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
// We only assert that an error propagates — the exact wording depends
|
||||
// on the aws-smithy layer.
|
||||
assert!(!msg.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_env_var_is_honoured() {
|
||||
std::env::set_var("KEI_STORE_S3_ENDPOINT", "http://127.0.0.1:9999");
|
||||
// cfg endpoint differs — env should win. Builder still succeeds.
|
||||
let cfg = S3Cfg {
|
||||
endpoint: Some("http://unused:1".to_string()),
|
||||
bucket: Some("b".to_string()),
|
||||
region: Some("us-east-1".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let s = S3CloudStore::new(cfg);
|
||||
std::env::remove_var("KEI_STORE_S3_ENDPOINT");
|
||||
assert!(s.is_ok());
|
||||
}
|
||||
Loading…
Reference in a new issue