Skip to content

Commit 76a5397

Browse files
fix: assorted arithmetic and indexing robustness fixes (#2451)
* fix(core): avoid i64 underflow on expired GCP token When a cached GCP access token was already expired (expires_at_ms < now), the remaining lifetime calculation used plain subtraction: (expires_at_ms - now) / 1000. This underflows in debug builds and wraps in release builds. Use saturating_sub so an expired token simply yields a non-positive remaining lifetime and is skipped. Add a regression test. Signed-off-by: Andrew White <andrewh@cdw.com> * fix(server): avoid i64 underflow in lease age calculation The lease steal path computed age as now_ms() - record.updated_at_ms. If the stored updated_at timestamp is in the future (e.g. clock skew), the subtraction underflows. Use saturating_sub to treat a future timestamp as age 0, keeping the lease considered fresh. Signed-off-by: Andrew White <andrewh@cdw.com> * fix(server): avoid i64 overflow on large credential max lifetime max_lifetime_seconds is only validated as >= 0. The code multiplied the raw i64 value by 1000 to compute max_expires, which overflows for values above i64::MAX / 1000. The capped i32 value used for the STS request was already computed but not reused. Compute max_lifetime_ms from the capped value with saturating_mul and use it for both expires_at fallback and max_expires. Signed-off-by: Andrew White <andrewh@cdw.com> * fix(server): avoid hardcoded indexing into base_url_config_keys resolve_vertex_ai_route assumed every inference provider profile has at least two base URL config keys by indexing [0] and [1]. Only the Vertex profile satisfies that today; other profiles would panic here. Iterate base_url_config_keys with find_map instead, and add a test with an empty key list to ensure no panic. Signed-off-by: Andrew White <andrewh@cdw.com> * fix(server): clamp lease age at zero for future timestamps Address review feedback: add regression coverage for future-timestamp lease clock skew. Extract lease_is_expired() and clamp the age at zero — i64::saturating_sub saturates at i64::MIN, not zero, so a future updated_at_ms must be clamped explicitly to be treated as age zero and remain unstealable with a positive TTL. Signed-off-by: Andrew White <andrewh@cdw.com> * fix(server): fall back past blank preferred Vertex base URL Address review feedback: find_map stopped at the first present key even when its value was blank, and the outer filter discarded it without considering lower-priority aliases. Filter blank values inside the closure so a blank preferred key falls back to a valid alias, matching the documented priority order. Add a regression test with a blank preferred key and a valid fallback. Signed-off-by: Andrew White <andrewh@cdw.com> * fix(server): saturate AWS STS expiry cap on saturated clock Address review feedback: now_ms + max_lifetime_ms could still overflow when current_time_ms() saturates near i64::MAX. Compute max_expires = now_ms.saturating_add(max_lifetime_ms) once and use it for both the conversion fallback and the expiry cap. Add a boundary unit test covering a saturated clock. Signed-off-by: Andrew White <andrewh@cdw.com> --------- Signed-off-by: Andrew White <andrewh@cdw.com>
1 parent 516be60 commit 76a5397

4 files changed

Lines changed: 133 additions & 12 deletions

File tree

crates/openshell-core/src/provider_credentials.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ impl ProviderCredentialState {
243243
DEFAULT_EXPIRES_IN
244244
} else {
245245
let now = crate::time::now_ms();
246-
(expires_at_ms - now) / 1000
246+
expires_at_ms.saturating_sub(now) / 1000
247247
}
248248
},
249249
);
@@ -587,6 +587,27 @@ mod tests {
587587
);
588588
}
589589

590+
#[test]
591+
fn gcp_token_response_handles_already_expired_token_without_panic() {
592+
let now_ms = i64::try_from(
593+
std::time::SystemTime::now()
594+
.duration_since(std::time::UNIX_EPOCH)
595+
.unwrap()
596+
.as_millis(),
597+
)
598+
.unwrap();
599+
let state = ProviderCredentialState::from_environment(
600+
1,
601+
HashMap::from([("GCP_ADC_ACCESS_TOKEN".to_string(), "adc-tok".to_string())]),
602+
HashMap::from([("GCP_ADC_ACCESS_TOKEN".to_string(), now_ms - 1_000)]),
603+
HashMap::new(),
604+
);
605+
assert!(
606+
state.gcp_token_response().is_none(),
607+
"expired token should be skipped rather than panic"
608+
);
609+
}
610+
590611
#[test]
591612
fn child_env_with_gcp_resolved_resolves_vertex_vars_without_metadata_host() {
592613
let state = ProviderCredentialState::from_environment(

crates/openshell-server/src/compute/lease.rs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,9 @@ impl ReconcilerLease {
121121
pub async fn try_steal_expired(&self) -> Result<LeaseGuard, LeaseError> {
122122
let record = self.read().await?.ok_or(LeaseError::NotFound)?;
123123

124-
let age_ms = now_ms() - record.updated_at_ms;
124+
let current_ms = now_ms();
125125
let ttl_ms = i64::try_from(self.ttl.as_millis()).unwrap_or(i64::MAX);
126-
if age_ms < ttl_ms {
126+
if !lease_is_expired(current_ms, record.updated_at_ms, ttl_ms) {
127127
return Err(LeaseError::AlreadyHeld);
128128
}
129129

@@ -252,6 +252,17 @@ pub fn replica_id() -> String {
252252
.unwrap_or_else(|_| uuid::Uuid::new_v4().to_string())
253253
}
254254

255+
/// True if a lease record's age exceeds its TTL.
256+
///
257+
/// Clamps at zero so a stored `updated_at_ms` in the future (clock skew)
258+
/// is treated as age zero and the lease is considered fresh. Note that
259+
/// `i64::saturating_sub` alone is not sufficient here: it saturates at
260+
/// `i64::MIN`, not zero.
261+
fn lease_is_expired(now_ms: i64, updated_at_ms: i64, ttl_ms: i64) -> bool {
262+
let age_ms = now_ms.saturating_sub(updated_at_ms).max(0);
263+
age_ms >= ttl_ms
264+
}
265+
255266
#[cfg(test)]
256267
mod tests {
257268
use super::*;
@@ -351,6 +362,28 @@ mod tests {
351362
assert_eq!(record.resource_version, guard.resource_version);
352363
}
353364

365+
#[test]
366+
fn future_updated_at_is_treated_as_age_zero() {
367+
let now = 1_000_000;
368+
let future_updated = now + 86_400_000; // 1 day in the future
369+
assert!(
370+
!lease_is_expired(now, future_updated, 1_000),
371+
"future updated_at_ms should not be stealable with positive TTL"
372+
);
373+
assert!(
374+
lease_is_expired(now, future_updated, 0),
375+
"TTL zero always expires regardless of timestamp"
376+
);
377+
assert!(
378+
!lease_is_expired(now, now, 1_000),
379+
"fresh lease with positive TTL should not be expired"
380+
);
381+
assert!(
382+
lease_is_expired(now, now, 0),
383+
"present timestamp with TTL zero should be expired"
384+
);
385+
}
386+
354387
#[tokio::test]
355388
async fn release_allows_immediate_reacquire() {
356389
let store = test_store().await;

crates/openshell-server/src/inference.rs

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -627,11 +627,11 @@ fn resolve_vertex_ai_route(
627627
// protocol and path contract, but only for the OpenAI-compatible Vertex surface.
628628
// Anthropic-on-Vertex needs model-path shaping and body adaptation that a fully
629629
// caller-controlled URL cannot safely preserve.
630-
if let Some(base_url) = config
631-
.get(profile.base_url_config_keys[0])
632-
.or_else(|| config.get(profile.base_url_config_keys[1]))
630+
if let Some(base_url) = profile
631+
.base_url_config_keys
632+
.iter()
633+
.find_map(|key| config.get(*key).filter(|v| !v.trim().is_empty()))
633634
.map(String::as_str)
634-
.filter(|v| !v.trim().is_empty())
635635
{
636636
if is_anthropic {
637637
return Err(Status::invalid_argument(
@@ -1133,6 +1133,65 @@ mod tests {
11331133
}
11341134
}
11351135

1136+
#[test]
1137+
fn resolve_vertex_ai_route_handles_empty_base_url_keys() {
1138+
let config = HashMap::new();
1139+
let profile = openshell_core::inference::InferenceProviderProfile {
1140+
provider_type: "google_vertex_ai",
1141+
default_base_url: "https://example.com",
1142+
protocols: &[],
1143+
credential_key_names: &[],
1144+
base_url_config_keys: &[],
1145+
auth: openshell_core::inference::AuthHeader::Bearer,
1146+
default_headers: &[],
1147+
passthrough_headers: &[],
1148+
};
1149+
let result = resolve_vertex_ai_route(&config, "model-id", "route", "api-key", &profile);
1150+
// Empty base_url_config_keys must not panic. The route still errors because
1151+
// the minimal Vertex config is missing, so we only assert reachability.
1152+
assert!(
1153+
result.is_err(),
1154+
"expected missing config to error, got {result:?}"
1155+
);
1156+
}
1157+
1158+
#[test]
1159+
fn resolve_vertex_ai_route_skips_blank_preferred_for_fallback() {
1160+
let mut config = HashMap::new();
1161+
config.insert("GOOGLE_VERTEX_AI_BASE_URL".to_string(), " ".to_string());
1162+
config.insert(
1163+
"VERTEX_AI_BASE_URL".to_string(),
1164+
"https://us-central1-aiplatform.googleapis.com".to_string(),
1165+
);
1166+
config.insert(
1167+
VERTEX_AI_PROJECT_ID_KEY.to_string(),
1168+
"my-project".to_string(),
1169+
);
1170+
config.insert(VERTEX_AI_REGION_KEY.to_string(), "us-central1".to_string());
1171+
1172+
let profile = openshell_core::inference::InferenceProviderProfile {
1173+
provider_type: "google-vertex-ai",
1174+
default_base_url: "",
1175+
protocols: &[],
1176+
credential_key_names: &[],
1177+
base_url_config_keys: &["GOOGLE_VERTEX_AI_BASE_URL", "VERTEX_AI_BASE_URL"],
1178+
auth: openshell_core::inference::AuthHeader::Bearer,
1179+
default_headers: &[],
1180+
passthrough_headers: &[],
1181+
};
1182+
1183+
let result = resolve_vertex_ai_route(&config, "model-id", "route", "api-key", &profile);
1184+
assert!(
1185+
result.is_ok(),
1186+
"blank preferred key should fall back to valid alias: {result:?}"
1187+
);
1188+
let route = result.unwrap();
1189+
assert_eq!(
1190+
route.endpoint,
1191+
"https://us-central1-aiplatform.googleapis.com"
1192+
);
1193+
}
1194+
11361195
#[test]
11371196
fn inference_bundle_requires_sandbox_principal() {
11381197
let sandbox = test_sandbox_principal();

crates/openshell-server/src/provider_refresh.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,7 @@ async fn mint_aws_sts_assume_role(
769769
DEFAULT_MAX_LIFETIME_SECONDS
770770
};
771771
let max_lifetime = i32::try_from(max_lifetime_i64.min(i64::from(i32::MAX))).unwrap_or(i32::MAX);
772+
let max_lifetime_ms = i64::from(max_lifetime).saturating_mul(1000);
772773

773774
let mut req = client
774775
.assume_role()
@@ -794,11 +795,8 @@ async fn mint_aws_sts_assume_role(
794795
let session_token = creds.session_token().to_string();
795796

796797
let now_ms = current_time_ms();
797-
let expires_at_ms = creds
798-
.expiration()
799-
.to_millis()
800-
.unwrap_or_else(|_| now_ms + max_lifetime_i64 * 1000);
801-
let max_expires = now_ms + max_lifetime_i64 * 1000;
798+
let max_expires = now_ms.saturating_add(max_lifetime_ms);
799+
let expires_at_ms = creds.expiration().to_millis().unwrap_or(max_expires);
802800
let expires_at_ms = expires_at_ms.min(max_expires);
803801

804802
// Map STS response fields to the env keys the profile bound to each semantic
@@ -1519,6 +1517,16 @@ mod tests {
15191517
);
15201518
}
15211519

1520+
#[test]
1521+
fn aws_sts_max_expires_saturates_on_saturated_clock() {
1522+
assert_eq!(i64::MAX.saturating_add(3_600_000), i64::MAX);
1523+
let now_ms = i64::MAX - 1_000;
1524+
let max_lifetime_ms = 3_600_000;
1525+
let max_expires = now_ms.saturating_add(max_lifetime_ms);
1526+
assert_eq!(max_expires, i64::MAX);
1527+
assert!(max_expires >= now_ms);
1528+
}
1529+
15221530
#[tokio::test]
15231531
async fn aws_sts_assume_role_mints_three_credentials_from_mock_endpoint() {
15241532
let mock_server = MockServer::start().await;

0 commit comments

Comments
 (0)