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:
Parfii-bot 2026-04-22 17:59:56 +08:00
commit 969ddf34cd
12 changed files with 1679 additions and 50 deletions

View file

@ -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.

View file

@ -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)

View file

@ -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) ---------------------------------------------------

File diff suppressed because it is too large Load diff

View file

@ -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"

View file

@ -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!(

View file

@ -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;

View 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);
}
}

View 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"));
}
}

View 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;

View 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"));
}

View 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());
}