itsi 0.2.21.rc1 → 0.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +3 -0
  3. data/.rubocop.yml +20 -0
  4. data/CHANGELOG.md +3 -0
  5. data/Cargo.lock +14 -14
  6. data/Dockerfile +9 -0
  7. data/crates/itsi_acme/src/caches/no.rs +1 -1
  8. data/crates/itsi_acme/src/caches/test.rs +3 -3
  9. data/crates/itsi_acme/src/config.rs +6 -6
  10. data/crates/itsi_acme/src/lib.rs +1 -1
  11. data/crates/itsi_error/Cargo.toml +1 -1
  12. data/crates/itsi_error/src/lib.rs +32 -15
  13. data/crates/itsi_rb_helpers/Cargo.toml +2 -2
  14. data/crates/itsi_rb_helpers/src/heap_value.rs +4 -4
  15. data/crates/itsi_rb_helpers/src/lib.rs +9 -5
  16. data/crates/itsi_scheduler/Cargo.toml +3 -3
  17. data/crates/itsi_scheduler/src/itsi_scheduler.rs +1 -1
  18. data/crates/itsi_server/Cargo.toml +3 -3
  19. data/crates/itsi_server/src/lib.rs +3 -2
  20. data/crates/itsi_server/src/ruby_types/itsi_body_proxy/big_bytes.rs +10 -3
  21. data/crates/itsi_server/src/ruby_types/itsi_body_proxy/mod.rs +10 -6
  22. data/crates/itsi_server/src/ruby_types/itsi_grpc_call.rs +7 -5
  23. data/crates/itsi_server/src/ruby_types/itsi_grpc_response_stream/mod.rs +2 -2
  24. data/crates/itsi_server/src/ruby_types/itsi_http_request.rs +10 -7
  25. data/crates/itsi_server/src/ruby_types/itsi_http_response.rs +13 -5
  26. data/crates/itsi_server/src/ruby_types/itsi_server/file_watcher.rs +6 -6
  27. data/crates/itsi_server/src/ruby_types/itsi_server/itsi_server_config.rs +15 -11
  28. data/crates/itsi_server/src/ruby_types/itsi_server.rs +34 -18
  29. data/crates/itsi_server/src/server/frame_stream.rs +2 -1
  30. data/crates/itsi_server/src/server/middleware_stack/middleware.rs +1 -1
  31. data/crates/itsi_server/src/server/middleware_stack/middlewares/compression.rs +1 -3
  32. data/crates/itsi_server/src/server/middleware_stack/middlewares/mod.rs +8 -2
  33. data/crates/itsi_server/src/server/middleware_stack/middlewares/proxy.rs +2 -2
  34. data/crates/itsi_server/src/server/middleware_stack/middlewares/redirect.rs +1 -1
  35. data/crates/itsi_server/src/server/middleware_stack/middlewares/ruby_app.rs +17 -7
  36. data/crates/itsi_server/src/server/middleware_stack/middlewares/static_assets.rs +90 -21
  37. data/crates/itsi_server/src/server/middleware_stack/mod.rs +12 -12
  38. data/crates/itsi_server/src/server/serve_strategy/cluster_mode.rs +4 -3
  39. data/crates/itsi_server/src/services/password_hasher.rs +8 -2
  40. data/crates/itsi_server/src/services/rate_limiter.rs +72 -25
  41. data/crates/itsi_server/src/services/static_file_server.rs +38 -13
  42. data/crates/itsi_tracing/src/lib.rs +3 -3
  43. data/gems/scheduler/Cargo.lock +3997 -541
  44. data/gems/scheduler/lib/itsi/scheduler/version.rb +1 -1
  45. data/gems/server/Cargo.lock +40 -13
  46. data/gems/server/lib/itsi/http_request.rb +22 -17
  47. data/gems/server/lib/itsi/rack_env_pool.rb +7 -17
  48. data/gems/server/lib/itsi/server/config/middleware/static_assets.rb +8 -1
  49. data/gems/server/lib/itsi/server/rack_interface.rb +12 -0
  50. data/gems/server/lib/itsi/server/version.rb +1 -1
  51. data/lib/itsi/version.rb +1 -1
  52. data/mise.toml +2 -0
  53. metadata +9 -5
@@ -49,7 +49,7 @@ impl StringMatch {
49
49
  let src_str = value.funcall::<_, _, String>("source", ())?;
50
50
  let regex = Regex::new(&src_str).map_err(|e| {
51
51
  magnus::Error::new(
52
- magnus::exception::standard_error(),
52
+ magnus::Ruby::get().unwrap().exception_standard_error(),
53
53
  format!("Invalid regexp: {}", e),
54
54
  )
55
55
  })?;
@@ -142,7 +142,7 @@ impl MiddlewareSet {
142
142
  let mut routes = vec![];
143
143
  for (index, route) in RArray::from_value(*routes_raw)
144
144
  .ok_or(magnus::Error::new(
145
- magnus::exception::standard_error(),
145
+ magnus::Ruby::get().unwrap().exception_standard_error(),
146
146
  format!("Routes must be an array. Got {:?}", routes_raw),
147
147
  ))?
148
148
  .into_iter()
@@ -152,18 +152,18 @@ impl MiddlewareSet {
152
152
  let route_raw = route_hash
153
153
  .get("route")
154
154
  .ok_or(magnus::Error::new(
155
- magnus::exception::standard_error(),
155
+ magnus::Ruby::get().unwrap().exception_standard_error(),
156
156
  "Route is missing :route key",
157
157
  ))?
158
158
  .funcall::<_, _, String>("source", ())?;
159
159
 
160
160
  let middleware =
161
161
  RHash::from_value(route_hash.get("middleware").ok_or(magnus::Error::new(
162
- magnus::exception::standard_error(),
162
+ magnus::Ruby::get().unwrap().exception_standard_error(),
163
163
  "Route is missing middleware key",
164
164
  ))?)
165
165
  .ok_or(magnus::Error::new(
166
- magnus::exception::standard_error(),
166
+ magnus::Ruby::get().unwrap().exception_standard_error(),
167
167
  format!("middleware must be a hash. Got {:?}", routes_raw),
168
168
  ))?;
169
169
 
@@ -179,7 +179,7 @@ impl MiddlewareSet {
179
179
  RArray::from_value(value)
180
180
  .ok_or_else(|| {
181
181
  magnus::Error::new(
182
- magnus::exception::type_error(),
182
+ magnus::Ruby::get().unwrap().exception_type_error(),
183
183
  "Expected array",
184
184
  )
185
185
  })
@@ -232,7 +232,7 @@ impl MiddlewareSet {
232
232
  Ok(Self {
233
233
  route_set: RegexSet::new(&routes).map_err(|e| {
234
234
  magnus::Error::new(
235
- magnus::exception::standard_error(),
235
+ magnus::Ruby::get().unwrap().exception_standard_error(),
236
236
  format!("Failed to create route set: {}", e),
237
237
  )
238
238
  })?,
@@ -243,7 +243,7 @@ impl MiddlewareSet {
243
243
  .collect::<std::result::Result<Vec<Regex>, regex::Error>>()
244
244
  .map_err(|e| {
245
245
  magnus::Error::new(
246
- magnus::exception::standard_error(),
246
+ magnus::Ruby::get().unwrap().exception_standard_error(),
247
247
  format!("Failed to create route set: {}", e),
248
248
  )
249
249
  })?
@@ -254,7 +254,7 @@ impl MiddlewareSet {
254
254
  })
255
255
  } else {
256
256
  Err(magnus::Error::new(
257
- magnus::exception::standard_error(),
257
+ magnus::Ruby::get().unwrap().exception_standard_error(),
258
258
  "Failed to create middleware stack",
259
259
  ))
260
260
  }
@@ -286,7 +286,7 @@ impl MiddlewareSet {
286
286
  self.route_set
287
287
  );
288
288
  Err(magnus::Error::new(
289
- magnus::exception::standard_error(),
289
+ magnus::Ruby::get().unwrap().exception_standard_error(),
290
290
  format!(
291
291
  "No matching middleware stack found for request: {:?}",
292
292
  request
@@ -338,7 +338,7 @@ impl MiddlewareSet {
338
338
  "app" => Ok(Middleware::RubyApp(RubyApp::from_value(parameters.into())?)),
339
339
  "proxy" => Ok(Middleware::Proxy(Proxy::from_value(parameters)?)),
340
340
  _ => Err(magnus::Error::new(
341
- magnus::exception::standard_error(),
341
+ magnus::Ruby::get().unwrap().exception_standard_error(),
342
342
  format!("Unknown filter type: {}", mw_type),
343
343
  )),
344
344
  }
@@ -349,7 +349,7 @@ impl MiddlewareSet {
349
349
  match result {
350
350
  Ok(result) => Ok(result),
351
351
  Err(err) => Err(magnus::Error::new(
352
- magnus::exception::standard_error(),
352
+ magnus::Ruby::get().unwrap().exception_standard_error(),
353
353
  format!(
354
354
  "Failed to instantiate middleware of type {}, due to {}",
355
355
  middleware_type, err
@@ -145,9 +145,10 @@ impl ClusterMode {
145
145
 
146
146
  call_with_gvl(|_| {
147
147
  create_ruby_thread(move || {
148
- call_without_gvl(move || match worker_clone.boot(self_clone) {
149
- Err(err) => error!("Worker boot failed {:?}", err),
150
- _ => {}
148
+ call_without_gvl(move || {
149
+ if let Err(err) = worker_clone.boot(self_clone) {
150
+ error!("Worker boot failed {:?}", err);
151
+ }
151
152
  })
152
153
  });
153
154
  });
@@ -4,7 +4,7 @@ use argon2::{
4
4
  };
5
5
 
6
6
  use itsi_error::ItsiError;
7
- use magnus::{error::Result, Value};
7
+ use magnus::{error::Result, Ruby, Value};
8
8
  use serde::Deserialize;
9
9
  use serde_magnus::deserialize;
10
10
  use sha_crypt::{
@@ -26,7 +26,13 @@ pub enum HashAlgorithm {
26
26
  }
27
27
 
28
28
  pub fn create_password_hash(password: String, algo: Value) -> Result<String> {
29
- let hash_algorithm: HashAlgorithm = deserialize(algo)?;
29
+ let ruby = Ruby::get().map_err(|_| {
30
+ magnus::Error::new(
31
+ magnus::Ruby::get().unwrap().exception_runtime_error(),
32
+ "Failed to acquire Ruby VM handle",
33
+ )
34
+ })?;
35
+ let hash_algorithm: HashAlgorithm = deserialize(&ruby, algo)?;
30
36
  match hash_algorithm {
31
37
  HashAlgorithm::Bcrypt => {
32
38
  // Use the bcrypt crate for password hashing.
@@ -6,9 +6,11 @@ use redis::{Client, RedisError, Script};
6
6
  use serde::Deserialize;
7
7
  use std::any::Any;
8
8
  use std::collections::{HashMap, HashSet};
9
+ use std::fmt::Write as _;
9
10
  use std::result::Result;
11
+ use std::sync::atomic::{AtomicU64, Ordering};
10
12
  use std::sync::{Arc, LazyLock};
11
- use std::time::{Duration, Instant};
13
+ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
12
14
  use tokio::sync::Mutex as AsyncMutex;
13
15
  use tokio::time::timeout;
14
16
  use tracing::warn;
@@ -303,27 +305,37 @@ impl RateLimiter for InMemoryRateLimiter {
303
305
 
304
306
  let mut entries = self.entries.write();
305
307
 
306
- let entry = entries
307
- .entry(key.to_string())
308
- .or_insert_with(|| RateLimitEntry {
309
- count: 0,
310
- expires_at: now + timeout,
311
- });
308
+ // Avoid per-request allocation: only allocate a String when inserting a new key.
309
+ //
310
+ // NOTE: we use `get_mut` first because `HashMap::entry` for `HashMap<String, _>`
311
+ // requires an owned `String`, which would allocate on every request.
312
+ if let Some(entry) = entries.get_mut(key) {
313
+ if entry.expires_at < now {
314
+ entry.expires_at = now + timeout;
315
+ entry.count = 1;
316
+ } else {
317
+ entry.count += 1;
318
+ }
319
+
320
+ let ttl = if entry.expires_at > now {
321
+ entry.expires_at.duration_since(now).as_secs()
322
+ } else {
323
+ 0
324
+ };
312
325
 
313
- if entry.expires_at < now {
314
- entry.expires_at = now + timeout;
315
- entry.count = 1;
316
- } else {
317
- entry.count += 1;
326
+ return Ok((entry.count, ttl));
318
327
  }
319
328
 
320
- let ttl = if entry.expires_at > now {
321
- entry.expires_at.duration_since(now).as_secs()
322
- } else {
323
- 0
324
- };
329
+ // Insert path: allocate once for the new key.
330
+ entries.insert(
331
+ key.to_owned(),
332
+ RateLimitEntry {
333
+ count: 1,
334
+ expires_at: now + timeout,
335
+ },
336
+ );
325
337
 
326
- Ok((entry.count, ttl))
338
+ Ok((1, timeout.as_secs()))
327
339
  }
328
340
 
329
341
  async fn check_limit(
@@ -378,14 +390,49 @@ impl BanManager {
378
390
  }
379
391
 
380
392
  /// Utility function to create a rate limit key for a specific minute
381
- pub fn create_rate_limit_key(api_key: &str, resource: &str) -> String {
382
- // Get the current minute number (0-59)
383
- let now = std::time::SystemTime::now()
384
- .duration_since(std::time::UNIX_EPOCH)
385
- .unwrap_or_default();
393
+ static CACHED_MINUTE_BUCKET_SECS: AtomicU64 = AtomicU64::new(0);
394
+ static CACHED_MINUTE_BUCKET: AtomicU64 = AtomicU64::new(0);
395
+
396
+ #[inline]
397
+ fn cached_minute_bucket() -> u64 {
398
+ // Cache the computed minute bucket and only refresh at most once per second.
399
+ // This avoids a syscall and divisions/mods on every request, while preserving
400
+ // the exact same value as the previous implementation.
401
+ let now_secs = SystemTime::now()
402
+ .duration_since(UNIX_EPOCH)
403
+ .unwrap_or_default()
404
+ .as_secs();
405
+
406
+ let last = CACHED_MINUTE_BUCKET_SECS.load(Ordering::Relaxed);
407
+ if last != now_secs {
408
+ let minutes = (now_secs / 60) % 60;
409
+ CACHED_MINUTE_BUCKET.store(minutes, Ordering::Relaxed);
410
+ CACHED_MINUTE_BUCKET_SECS.store(now_secs, Ordering::Relaxed);
411
+ minutes
412
+ } else {
413
+ CACHED_MINUTE_BUCKET.load(Ordering::Relaxed)
414
+ }
415
+ }
386
416
 
387
- let minutes = now.as_secs() / 60 % 60;
388
- format!("ratelimit:{}:{}:{}", api_key, resource, minutes)
417
+ pub fn create_rate_limit_key(api_key: &str, resource: &str) -> String {
418
+ let minutes = cached_minute_bucket();
419
+
420
+ // Build the exact same string as:
421
+ // format!("ratelimit:{}:{}:{}", api_key, resource, minutes)
422
+ // but avoid `format!` machinery and minimize reallocations.
423
+ let mut s = String::with_capacity(
424
+ "ratelimit:".len() + api_key.len() + 1 + resource.len() + 1 + 2, // minutes is 0-59
425
+ );
426
+
427
+ s.push_str("ratelimit:");
428
+ s.push_str(api_key);
429
+ s.push(':');
430
+ s.push_str(resource);
431
+ s.push(':');
432
+ // u64->decimal without intermediate allocation
433
+ let _ = write!(&mut s, "{}", minutes);
434
+
435
+ s
389
436
  }
390
437
 
391
438
  /// Utility function to create a ban key for an IP address
@@ -127,6 +127,38 @@ impl CacheEntry {
127
127
  &self,
128
128
  supported_encodings: &[HeaderValue],
129
129
  ) -> (Arc<Bytes>, Option<HeaderValue>) {
130
+ // Fast-path: if the caller already computed a preferred single encoding token,
131
+ // it will pass exactly one HeaderValue (e.g. "br"). This avoids per-request
132
+ // string splitting/parsing on the cached static file hot-path.
133
+ if supported_encodings.len() == 1 {
134
+ let hv = &supported_encodings[0];
135
+ if hv == HEADER_VALUE_ZSTD {
136
+ if let Some(zstd) = self.zstd.as_ref() {
137
+ return (zstd.clone(), Some(HEADER_VALUE_ZSTD.clone()));
138
+ }
139
+ return (self.content.clone(), None);
140
+ }
141
+ if hv == HEADER_VALUE_BR {
142
+ if let Some(br) = self.br.as_ref() {
143
+ return (br.clone(), Some(HEADER_VALUE_BR.clone()));
144
+ }
145
+ return (self.content.clone(), None);
146
+ }
147
+ if hv == HEADER_VALUE_GZIP {
148
+ if let Some(gz) = self.gz.as_ref() {
149
+ return (gz.clone(), Some(HEADER_VALUE_GZIP.clone()));
150
+ }
151
+ return (self.content.clone(), None);
152
+ }
153
+ if hv == HEADER_VALUE_DEFLATE {
154
+ if let Some(deflate) = self.deflate.as_ref() {
155
+ return (deflate.clone(), Some(HEADER_VALUE_DEFLATE.clone()));
156
+ }
157
+ return (self.content.clone(), None);
158
+ }
159
+ }
160
+
161
+ // Slow-path: parse Accept-Encoding values and select the first supported encoding.
130
162
  for encoding_header in supported_encodings {
131
163
  if let Ok(header_value) = encoding_header.to_str() {
132
164
  for header_value in header_value.split(",").map(|hv| hv.trim()) {
@@ -416,15 +448,12 @@ impl StaticFileServer {
416
448
  abs_path: &str,
417
449
  accept: ResponseFormat,
418
450
  ) -> std::result::Result<ResolvedAsset, NotFoundBehavior> {
419
- let ext_opt = Path::new(key)
420
- .extension()
421
- .and_then(|e| e.to_str())
422
- .map(|s| s.to_lowercase());
451
+ let ext_opt = Path::new(key).extension().and_then(|e| e.to_str());
423
452
 
424
453
  // If the allowed list is non-empty, enforce membership
425
454
  if !self.allowed_extensions.is_empty() {
426
455
  match ext_opt {
427
- Some(ref ext)
456
+ Some(ext)
428
457
  if self
429
458
  .allowed_extensions
430
459
  .iter()
@@ -569,8 +598,7 @@ impl StaticFileServer {
569
598
  }
570
599
  }
571
600
  }
572
- if index_file.is_some() {
573
- let index_path = index_file.unwrap();
601
+ if let Some(index_path) = index_file {
574
602
  self.key_to_path
575
603
  .lock()
576
604
  .insert(key.to_string(), index_path.clone());
@@ -876,10 +904,7 @@ impl StaticFileServer {
876
904
  content_length.to_string()
877
905
  },
878
906
  )
879
- .header(
880
- "Last-Modified",
881
- format_http_date_header(cache_entry.last_modified),
882
- );
907
+ .header("Last-Modified", cache_entry.last_modified_http_date.clone());
883
908
 
884
909
  if let Some(range) = content_range {
885
910
  builder = builder.header("Content-Range", range);
@@ -1228,8 +1253,8 @@ async fn generate_directory_listing(
1228
1253
  });
1229
1254
 
1230
1255
  // Serialize the JSON object to a pretty-printed string.
1231
- let json_string = serde_json::to_string_pretty(&json_obj)
1232
- .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
1256
+ let json_string =
1257
+ serde_json::to_string_pretty(&json_obj).map_err(std::io::Error::other)?;
1233
1258
 
1234
1259
  Ok(json_string)
1235
1260
  }
@@ -1,4 +1,4 @@
1
- use atty::{Stream, is};
1
+ use atty::{is, Stream};
2
2
  use std::{
3
3
  env,
4
4
  sync::{Mutex, OnceLock},
@@ -10,8 +10,8 @@ use tracing_subscriber::fmt::{
10
10
  format::{FmtSpan, JsonFields},
11
11
  writer::BoxMakeWriter,
12
12
  };
13
- use tracing_subscriber::{EnvFilter, fmt, prelude::*, reload};
14
- use tracing_subscriber::{Layer, Registry, layer::Layered};
13
+ use tracing_subscriber::{fmt, prelude::*, reload, EnvFilter};
14
+ use tracing_subscriber::{layer::Layered, Layer, Registry};
15
15
 
16
16
  // Global reload handle for changing the level at runtime.
17
17
  static RELOAD_HANDLE: OnceLock<