Promotes S3 from MVP stub to functional via aws-sdk-s3. Default builds
unchanged (zero new deps). Feature flag ensures users who don't need
S3 don't pay the ~5MB binary / C-toolchain cost.
Cargo.toml: new [features] s3 = [...] gating 4 optional deps:
aws-sdk-s3 = 1.130.0
aws-config = 1.8.16 (with behavior-version-latest)
tokio = 1.52.1 (current-thread runtime, no multi-threaded bloat)
bytes = 1 (S3 body passthrough)
s3_cloud/ module (4 files, Constructor Pattern):
mod.rs (190 LOC) — S3CloudStore + MemoryStore trait impl
client.rs (81 LOC) — aws-config builder, KEI_STORE_S3_ENDPOINT
override for R2 / Wasabi / MinIO / any S3-compat
keys.rs (60 LOC) — path-traversal guard + DJB2 hash helper
tests.rs (63 LOC) — builder + prefix + key-guard unit tests
Factory routing (factory.rs):
with 's3' feature + bucket URL → S3CloudStore (real network)
without 's3' feature → S3Store stub (existing MVP, preserved)
Security posture:
- Branch-prefix isolation rejects traversal at keys.rs layer
- aws-config default credential chain (env → ~/.aws → IMDS);
no bespoke credential handling
- rustls, not OpenSSL (matches existing crate tree)
Tests: 22 existing + 11 new (4 keys + 3 client + 5 mod + 5 smoke)
cargo test -p kei-store (default features): 9 passed
cargo test -p kei-store --features s3: 22 + 9 + 5 = 36 passed
cargo clippy -p kei-store --features s3: clean
Real stdout verified for all verify criteria. No fabrication.
MANIFEST.toml [primitive.kei-store] deps updated to reflect feature
opt-in model.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
81 lines
2.7 KiB
Rust
81 lines
2.7 KiB
Rust
//! 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());
|
|
}
|