mesofact_dev/s3.rs
1//! Dev-tier S3 surface (R490-F7).
2//!
3//! An FS-backed [s3s-fs] endpoint that `mesofact-dev` hosts so a workload's
4//! `@mesofact/runtime` `R2Adapter` can resolve against a *local* bucket during
5//! `bun run dev` instead of real Cloudflare R2. This fills the
6//! "build→PUT→read" contract that the R255-S5 spike explicitly left
7//! unexercised at tier 1 — that spike only ruled on the static-asset
8//! browser-GET path (still serve-off-disk); the runtime `r2` adapter is a
9//! separate consumer that *does* speak the S3 API.
10//!
11//! Design notes:
12//! - **Anonymous.** Bound to `127.0.0.1` on a dynamic port, this is a
13//! single-tenant loopback dev appliance — no SigV4 verification. The
14//! `R2Adapter` still signs with `aws4fetch` using whatever dummy creds the
15//! workload injects; s3s ignores the signature when no auth is configured.
16//! - **Bucket = a pre-created dir under the state root.** s3s-fs treats
17//! top-level dirs under its root as buckets, so creating `<root>/<bucket>/`
18//! up front is enough for `PutObject` to land.
19//! - **Out of scope here (handed off):** seeding the bucket from `dist/`, and
20//! injecting coords into the *in-process V8 SSR runtime* (which can't inherit
21//! `process.env` the way the build subprocess does). See R490-F7.
22//!
23//! [s3s-fs]: https://docs.rs/s3s-fs
24
25use std::net::Ipv4Addr;
26use std::path::{Path, PathBuf};
27
28use anyhow::{Context, Result};
29use tokio::net::TcpListener;
30
31/// Default dev bucket name. Workloads point `[sources.r2] bucket` here in dev.
32pub const DEFAULT_BUCKET: &str = "dev";
33
34/// Dummy credentials the surface accepts. SigV4-signing clients (the publisher's
35/// `S3Store`, the JS `R2Adapter`) sign with these; s3s verifies the signature
36/// against them. Handed to consumers verbatim via [`DevS3::env_vars`].
37const DEV_ACCESS_KEY: &str = "dev";
38const DEV_SECRET_KEY: &str = "dev";
39
40/// Coordinates of a running dev S3 surface, handed to consumers (build-child
41/// env, discovery file) so they can point an S3 client at it.
42#[derive(Debug, Clone)]
43pub struct DevS3 {
44 /// e.g. `http://127.0.0.1:54321` — no trailing slash, path-style.
45 pub endpoint: String,
46 /// The single dev bucket, pre-created on disk.
47 pub bucket: String,
48 /// On-disk root backing the surface (`<state_dir>/s3`).
49 pub root: PathBuf,
50}
51
52impl DevS3 {
53 /// Start the surface: create `<root>/<bucket>/`, bind `127.0.0.1:0`, and
54 /// spawn the serve loop on the current tokio runtime. Returns the bound
55 /// coordinates; the server runs until the process exits.
56 pub async fn start(root: impl Into<PathBuf>, bucket: &str) -> Result<DevS3> {
57 let root = root.into();
58 let bucket_dir = root.join(bucket);
59 tokio::fs::create_dir_all(&bucket_dir)
60 .await
61 .with_context(|| format!("creating dev S3 bucket dir {}", bucket_dir.display()))?;
62
63 let service = build_service(&root)?;
64
65 let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
66 .await
67 .context("binding dev S3 listener")?;
68 let addr = listener.local_addr().context("dev S3 local_addr")?;
69 let endpoint = format!("http://{addr}");
70
71 tokio::spawn(serve_loop(listener, service));
72
73 Ok(DevS3 {
74 endpoint,
75 bucket: bucket.to_string(),
76 root,
77 })
78 }
79
80 /// Conventional env vars mesofact-dev injects so a workload's `[sources.r2]`
81 /// can resolve in dev: `R2_ENDPOINT`, `R2_BUCKET`, plus dummy credentials
82 /// (the surface is anonymous, but the adapter's config still requires the
83 /// key/secret env vars to be present to register the source).
84 pub fn env_vars(&self) -> Vec<(String, String)> {
85 vec![
86 ("R2_ENDPOINT".to_string(), self.endpoint.clone()),
87 ("R2_BUCKET".to_string(), self.bucket.clone()),
88 ("R2_ACCESS_KEY_ID".to_string(), DEV_ACCESS_KEY.to_string()),
89 ("R2_SECRET_ACCESS_KEY".to_string(), DEV_SECRET_KEY.to_string()),
90 ]
91 }
92}
93
94/// Permissive access layer: allow every request, authenticated or not. s3s only
95/// consults `S3Access` when an auth provider is configured, so pairing this with
96/// [`SimpleAuth`](s3s::auth::SimpleAuth) means signed `dev/dev` requests verify
97/// AND unsigned loopback requests still pass — preserving the anonymous
98/// dev-appliance contract while unblocking SigV4 clients.
99struct AllowAllAccess;
100
101#[async_trait::async_trait]
102impl s3s::access::S3Access for AllowAllAccess {
103 async fn check(&self, _cx: &mut s3s::access::S3AccessContext<'_>) -> s3s::S3Result<()> {
104 Ok(())
105 }
106}
107
108fn build_service(root: &Path) -> Result<s3s::service::S3Service> {
109 use s3s::auth::SimpleAuth;
110 use s3s::service::S3ServiceBuilder;
111 // s3s_fs::Error doesn't impl std::error::Error, so map it by Display.
112 let fs = s3s_fs::FileSystem::new(root)
113 .map_err(|e| anyhow::anyhow!("opening s3s-fs at {}: {e:?}", root.display()))?;
114 let mut builder = S3ServiceBuilder::new(fs);
115 // Accept SigV4-signed requests. Without ANY auth provider s3s answers 501
116 // ("no authentication provider") to every *signed* request — which breaks
117 // the `S3Store`-based pointer/content reads the local publish→view loop
118 // needs (W270 §9), and the R2Adapter signs too. SimpleAuth verifies the
119 // dev/dev signature; AllowAllAccess keeps anonymous loopback access working,
120 // so the surface stays the single-tenant dev appliance it was.
121 builder.set_auth(SimpleAuth::from_single(DEV_ACCESS_KEY, DEV_SECRET_KEY));
122 builder.set_access(AllowAllAccess);
123 Ok(builder.build())
124}
125
126async fn serve_loop(listener: TcpListener, service: s3s::service::S3Service) {
127 use hyper_util::rt::{TokioExecutor, TokioIo};
128 use hyper_util::server::conn::auto::Builder as ConnBuilder;
129
130 let http = ConnBuilder::new(TokioExecutor::new());
131 loop {
132 let socket = match listener.accept().await {
133 Ok((socket, _)) => socket,
134 Err(e) => {
135 tracing::warn!(error = %e, "dev S3: accept failed");
136 continue;
137 }
138 };
139 // `.into_owned()` detaches the connection future from the borrowed
140 // builder so it can be spawned with a `'static` lifetime.
141 let conn = http
142 .serve_connection(TokioIo::new(socket), service.clone())
143 .into_owned();
144 tokio::spawn(async move {
145 if let Err(e) = conn.await {
146 tracing::debug!(error = %e, "dev S3: connection ended");
147 }
148 });
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 #[tokio::test]
157 async fn start_creates_bucket_dir() {
158 let tmp = tempfile::tempdir().unwrap();
159 let s3 = DevS3::start(tmp.path().join("s3"), DEFAULT_BUCKET)
160 .await
161 .unwrap();
162 assert!(s3.root.join(DEFAULT_BUCKET).is_dir());
163 assert!(s3.endpoint.starts_with("http://127.0.0.1:"));
164 assert_eq!(s3.bucket, DEFAULT_BUCKET);
165 }
166
167 #[tokio::test]
168 async fn put_then_get_round_trips() {
169 let tmp = tempfile::tempdir().unwrap();
170 let s3 = DevS3::start(tmp.path().join("s3"), DEFAULT_BUCKET)
171 .await
172 .unwrap();
173
174 let url = format!("{}/{}/hello.txt", s3.endpoint, s3.bucket);
175 let client = reqwest::Client::new();
176
177 // Anonymous PUT (s3s skips signature checks with no auth configured).
178 let put = client
179 .put(&url)
180 .body("WORLD")
181 .send()
182 .await
183 .expect("PUT request");
184 assert!(
185 put.status().is_success(),
186 "PUT status: {} body: {:?}",
187 put.status(),
188 put.text().await
189 );
190
191 let get = client.get(&url).send().await.expect("GET request");
192 assert!(get.status().is_success(), "GET status: {}", get.status());
193 assert_eq!(get.text().await.unwrap(), "WORLD");
194
195 // And it actually landed on disk under the bucket dir.
196 assert!(s3.root.join(DEFAULT_BUCKET).join("hello.txt").is_file());
197 }
198}