feat(gating): auto-update Base+, features[] override fail-closed, dev-override (Rust)
Re-gate auto-update to Base+Premium now that paid activation works end-to-end (absorbs #271), and align the Rust entitlement layer with the TS matrix shipped in #297: - FEATURE_TIERS: auto-update -> [base, premium]; the 'temporarily open' carve-out and its test are gone (free_allows_auto_update_temporarily -> free_denied_auto_update). Dead rows web-sync, cloud-backup and advanced-reports are purged (no call-site anywhere; advanced-reports -> Premium contradicted the TS reports-advanced -> Base+ matrix). Only auto-update remains on the Rust side. - features[] override, fail-closed in Free (CWE-863): new current_entitlements() resolves the edition AND the signed features[] through the same machine-binding path — every downgrade path returns ('free', []) so a copied license.key can never keep its signed features. check_entitlement combines them via the new pure is_entitled(): is_feature_allowed(feature, edition) || features.contains(feature), with a defense-in-depth free short-circuit mirroring the TS isEntitled. current_edition() now delegates to current_entitlements() — single resolution path, no drift possible. - dev-override: new Cargo feature (off by default, never in a release feature set — CWE-489: debug_assertions could be flipped on a custom release build and become a Premium backdoor). Only when compiled in, SR_DEV_EDITION forces the edition (free|base|premium) to test tiers locally. A feature-off test proves the env var has zero effect in normal builds; feature-on companions (env access serialized by a mutex) cover cargo test --features dev-override. No Tauri command signature changes: check_entitlement keeps its (feature: String) -> Result<bool, String> contract for useUpdater.ts and ErrorPage.tsx. Validation: cargo check + cargo test (106 passed, feature off) + cargo test --features dev-override + npm test (871) + npm run build. Resolves #301 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b89074e6c6
commit
17833cf942
3 changed files with 299 additions and 68 deletions
|
|
@ -66,3 +66,13 @@ hmac = "0.12"
|
||||||
ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] }
|
ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] }
|
||||||
# HTTP mock server for balance_commands fetch_price tests (Issue #155).
|
# HTTP mock server for balance_commands fetch_price tests (Issue #155).
|
||||||
mockito = "1.6"
|
mockito = "1.6"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# Dev-only escape hatch: when enabled, the SR_DEV_EDITION env var forces the
|
||||||
|
# resolved edition (free|base|premium) so the three license tiers can be tested
|
||||||
|
# without real license files. MUST stay out of `default` and of any release
|
||||||
|
# feature set — gating this on debug_assertions instead would let a custom
|
||||||
|
# release build honor the env var and become a Premium backdoor (CWE-489).
|
||||||
|
# Usage: cargo test --features dev-override, or `tauri dev` with
|
||||||
|
# `-- --features dev-override`.
|
||||||
|
dev-override = []
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
// Centralized feature → tier mapping for license entitlements.
|
// Centralized feature → tier mapping for license entitlements.
|
||||||
//
|
//
|
||||||
// This module is the single source of truth for which features are gated by which tier.
|
// This module is the single source of truth for which features are gated by which tier
|
||||||
// To change what is gated where, modify FEATURE_TIERS only — never sprinkle edition checks
|
// on the Rust side. To change what is gated where, modify FEATURE_TIERS only — never
|
||||||
// throughout the codebase.
|
// sprinkle edition checks throughout the codebase.
|
||||||
|
//
|
||||||
|
// Since the tier-gating work (#297-#301), UI-level gates (budget, adjustments,
|
||||||
|
// reports-advanced, multi-profile, balance) live in the TS matrix
|
||||||
|
// `src/shared/entitlements.ts` (soft-paywall, UI-only enforcement). This table
|
||||||
|
// only keeps the features actually checked through `check_entitlement` — the
|
||||||
|
// JWT `features[]` array is a namespace shared with that TS layer, so keys are
|
||||||
|
// kebab-case on both sides.
|
||||||
|
|
||||||
/// Editions, ordered from least to most privileged.
|
/// Editions, ordered from least to most privileged.
|
||||||
pub const EDITION_FREE: &str = "free";
|
pub const EDITION_FREE: &str = "free";
|
||||||
|
|
@ -10,17 +17,11 @@ pub const EDITION_BASE: &str = "base";
|
||||||
pub const EDITION_PREMIUM: &str = "premium";
|
pub const EDITION_PREMIUM: &str = "premium";
|
||||||
|
|
||||||
/// Maps feature name → list of editions allowed to use it.
|
/// Maps feature name → list of editions allowed to use it.
|
||||||
/// A feature absent from this list is denied for all editions.
|
/// A feature absent from this list is denied for all editions, unless the
|
||||||
const FEATURE_TIERS: &[(&str, &[&str])] = &[
|
/// license carries it in its signed `features[]` override (see [`is_entitled`]).
|
||||||
// auto-update is temporarily open to FREE until the license server (issue #49)
|
const FEATURE_TIERS: &[(&str, &[&str])] = &[("auto-update", &[EDITION_BASE, EDITION_PREMIUM])];
|
||||||
// is live. Re-gate to [BASE, PREMIUM] once paid activation works end-to-end.
|
|
||||||
("auto-update", &[EDITION_FREE, EDITION_BASE, EDITION_PREMIUM]),
|
|
||||||
("web-sync", &[EDITION_PREMIUM]),
|
|
||||||
("cloud-backup", &[EDITION_PREMIUM]),
|
|
||||||
("advanced-reports", &[EDITION_PREMIUM]),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Pure check: does `edition` grant access to `feature`?
|
/// Pure check: does `edition` grant access to `feature` via the static matrix?
|
||||||
pub fn is_feature_allowed(feature: &str, edition: &str) -> bool {
|
pub fn is_feature_allowed(feature: &str, edition: &str) -> bool {
|
||||||
FEATURE_TIERS
|
FEATURE_TIERS
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -29,10 +30,28 @@ pub fn is_feature_allowed(feature: &str, edition: &str) -> bool {
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Static matrix check OR signed per-license `features[]` override.
|
||||||
|
///
|
||||||
|
/// Fail-closed in Free (CWE-863): a `license.key` copied onto another machine
|
||||||
|
/// resolves to "free" through the machine-binding path, which already drops the
|
||||||
|
/// signed features (see `license_commands::current_entitlements`). As defense
|
||||||
|
/// in depth we also refuse the override here whenever the edition is free, so
|
||||||
|
/// signed features can never rescue a downgraded license.
|
||||||
|
pub fn is_entitled(feature: &str, edition: &str, features: &[String]) -> bool {
|
||||||
|
if edition == EDITION_FREE {
|
||||||
|
return is_feature_allowed(feature, edition);
|
||||||
|
}
|
||||||
|
is_feature_allowed(feature, edition) || features.iter().any(|f| f == feature)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tauri command: is `feature` available right now? Edition AND signed
|
||||||
|
/// per-license feature overrides are resolved through the same machine-binding
|
||||||
|
/// path (`license_commands::current_entitlements`), then combined by
|
||||||
|
/// [`is_entitled`].
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn check_entitlement(app: tauri::AppHandle, feature: String) -> Result<bool, String> {
|
pub fn check_entitlement(app: tauri::AppHandle, feature: String) -> Result<bool, String> {
|
||||||
let edition = crate::commands::license_commands::current_edition(&app);
|
let (edition, features) = crate::commands::license_commands::current_entitlements(&app);
|
||||||
Ok(is_feature_allowed(&feature, &edition))
|
Ok(is_entitled(&feature, &edition, &features))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -40,9 +59,8 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn free_allows_auto_update_temporarily() {
|
fn free_denied_auto_update() {
|
||||||
// Temporary: auto-update is open to FREE until the license server is live.
|
assert!(!is_feature_allowed("auto-update", EDITION_FREE));
|
||||||
assert!(is_feature_allowed("auto-update", EDITION_FREE));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -51,20 +69,43 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn premium_unlocks_everything() {
|
fn premium_unlocks_auto_update() {
|
||||||
assert!(is_feature_allowed("auto-update", EDITION_PREMIUM));
|
assert!(is_feature_allowed("auto-update", EDITION_PREMIUM));
|
||||||
assert!(is_feature_allowed("web-sync", EDITION_PREMIUM));
|
|
||||||
assert!(is_feature_allowed("cloud-backup", EDITION_PREMIUM));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn base_does_not_unlock_premium_features() {
|
|
||||||
assert!(!is_feature_allowed("web-sync", EDITION_BASE));
|
|
||||||
assert!(!is_feature_allowed("cloud-backup", EDITION_BASE));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_feature_denied() {
|
fn unknown_feature_denied() {
|
||||||
assert!(!is_feature_allowed("nonexistent", EDITION_PREMIUM));
|
assert!(!is_feature_allowed("nonexistent", EDITION_PREMIUM));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matrix_grants_without_override() {
|
||||||
|
assert!(is_entitled("auto-update", EDITION_BASE, &[]));
|
||||||
|
assert!(is_entitled("auto-update", EDITION_PREMIUM, &[]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unlisted_feature_denied_without_override() {
|
||||||
|
// "balance" lives in the TS matrix only — absent from FEATURE_TIERS,
|
||||||
|
// so the static path denies it for every edition.
|
||||||
|
assert!(!is_entitled("balance", EDITION_BASE, &[]));
|
||||||
|
assert!(!is_entitled("balance", EDITION_PREMIUM, &[]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn override_grants_unlisted_feature_for_paid_editions() {
|
||||||
|
let features = vec!["balance".to_string()];
|
||||||
|
assert!(is_entitled("balance", EDITION_BASE, &features));
|
||||||
|
assert!(is_entitled("balance", EDITION_PREMIUM, &features));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn override_ignored_in_free() {
|
||||||
|
// CWE-863: signed features[] must never rescue a license downgraded to
|
||||||
|
// free (copied key / machine mismatch) — not even for a feature that a
|
||||||
|
// paid edition would get from the static matrix.
|
||||||
|
let features = vec!["balance".to_string(), "auto-update".to_string()];
|
||||||
|
assert!(!is_entitled("balance", EDITION_FREE, &features));
|
||||||
|
assert!(!is_entitled("auto-update", EDITION_FREE, &features));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -221,51 +221,117 @@ pub fn get_edition(app: tauri::AppHandle) -> Result<String, String> {
|
||||||
Ok(current_edition(&app))
|
Ok(current_edition(&app))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Internal helper used by `entitlements::check_entitlement`. Never returns an error — any
|
/// Dev-only edition override, compiled in ONLY under the `dev-override` Cargo
|
||||||
/// failure resolves to "free" so feature gates fail closed.
|
/// feature (off by default and absent from any release feature set). Gating on
|
||||||
///
|
/// `debug_assertions` instead would be CWE-489: a custom release build could
|
||||||
/// Priority: Premium (via Compte Maximus with active subscription) > Base (offline license) > Free.
|
/// flip it on and the env var would become a Premium backdoor. When compiled
|
||||||
pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
|
/// in, `SR_DEV_EDITION` forces the resolved edition (free|base|premium) so the
|
||||||
// Check Compte Maximus subscription first — Premium overrides Base
|
/// three tiers can be tested without real licenses; unrecognized values are
|
||||||
if let Some(edition) = check_account_edition(app) {
|
/// ignored and resolution falls through to the normal path.
|
||||||
if edition == EDITION_PREMIUM {
|
fn dev_override_edition() -> Option<String> {
|
||||||
return edition;
|
#[cfg(feature = "dev-override")]
|
||||||
}
|
{
|
||||||
}
|
if let Ok(edition) = std::env::var("SR_DEV_EDITION") {
|
||||||
|
if edition == EDITION_FREE || edition == EDITION_BASE || edition == EDITION_PREMIUM {
|
||||||
let Ok(path) = license_path(app) else {
|
return Some(edition);
|
||||||
return EDITION_FREE.to_string();
|
|
||||||
};
|
|
||||||
if !path.exists() {
|
|
||||||
return EDITION_FREE.to_string();
|
|
||||||
}
|
|
||||||
let Ok(key) = fs::read_to_string(&path) else {
|
|
||||||
return EDITION_FREE.to_string();
|
|
||||||
};
|
|
||||||
let Ok(decoding_key) = embedded_decoding_key() else {
|
|
||||||
return EDITION_FREE.to_string();
|
|
||||||
};
|
|
||||||
let Ok(info) = validate_with_key(&key, &decoding_key) else {
|
|
||||||
return EDITION_FREE.to_string();
|
|
||||||
};
|
|
||||||
|
|
||||||
// If an activation token exists, it must match the local machine. A missing token is
|
|
||||||
// accepted (graceful pre-activation).
|
|
||||||
if let Ok(activation_path) = activation_path(app) {
|
|
||||||
if activation_path.exists() {
|
|
||||||
let Ok(token) = fs::read_to_string(&activation_path) else {
|
|
||||||
return EDITION_FREE.to_string();
|
|
||||||
};
|
|
||||||
let Ok(local_id) = machine_id_internal() else {
|
|
||||||
return EDITION_FREE.to_string();
|
|
||||||
};
|
|
||||||
if validate_activation_with_key(&token, &local_id, &decoding_key).is_err() {
|
|
||||||
return EDITION_FREE.to_string();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
info.edition
|
/// Pure resolution of `(edition, signed features)` from license material.
|
||||||
|
///
|
||||||
|
/// Single choke point for the machine-binding rule: every downgrade path
|
||||||
|
/// returns `("free", [])`, so the signed `features[]` of a copied license can
|
||||||
|
/// never be honored once the edition is downgraded (CWE-863). Separated from
|
||||||
|
/// the fs/AppHandle plumbing so tests can exercise it with in-memory keys.
|
||||||
|
fn resolve_license_entitlements(
|
||||||
|
license_key: &str,
|
||||||
|
activation_token: Option<&str>,
|
||||||
|
local_machine_id: &str,
|
||||||
|
decoding_key: &DecodingKey,
|
||||||
|
) -> (String, Vec<String>) {
|
||||||
|
let Ok(info) = validate_with_key(license_key, decoding_key) else {
|
||||||
|
return (EDITION_FREE.to_string(), Vec::new());
|
||||||
|
};
|
||||||
|
// If an activation token exists, it must match the local machine. A missing
|
||||||
|
// token is accepted (graceful pre-activation state).
|
||||||
|
if let Some(token) = activation_token {
|
||||||
|
if validate_activation_with_key(token, local_machine_id, decoding_key).is_err() {
|
||||||
|
return (EDITION_FREE.to_string(), Vec::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(info.edition, info.features)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal helper used by `entitlements::check_entitlement`. Resolves the
|
||||||
|
/// effective edition AND the signed per-license feature overrides through the
|
||||||
|
/// SAME machine-binding path. Never returns an error — any failure resolves to
|
||||||
|
/// `("free", [])` so feature gates fail closed, features included (CWE-863: a
|
||||||
|
/// copied `license.key` must not keep its signed `features[]` once downgraded).
|
||||||
|
///
|
||||||
|
/// Priority: dev override (dev-override builds only) > Premium (via Compte
|
||||||
|
/// Maximus with active subscription) > Base (offline license) > Free.
|
||||||
|
pub(crate) fn current_entitlements(app: &tauri::AppHandle) -> (String, Vec<String>) {
|
||||||
|
// Dev-only tier testing — compiled out of normal builds (see the helper).
|
||||||
|
if let Some(edition) = dev_override_edition() {
|
||||||
|
return (edition, Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Compte Maximus subscription first — Premium overrides Base. This
|
||||||
|
// path never reads the license JWT, so it carries no signed features.
|
||||||
|
if let Some(edition) = check_account_edition(app) {
|
||||||
|
if edition == EDITION_PREMIUM {
|
||||||
|
return (edition, Vec::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let free = || (EDITION_FREE.to_string(), Vec::new());
|
||||||
|
|
||||||
|
let Ok(path) = license_path(app) else {
|
||||||
|
return free();
|
||||||
|
};
|
||||||
|
if !path.exists() {
|
||||||
|
return free();
|
||||||
|
}
|
||||||
|
let Ok(key) = fs::read_to_string(&path) else {
|
||||||
|
return free();
|
||||||
|
};
|
||||||
|
let Ok(decoding_key) = embedded_decoding_key() else {
|
||||||
|
return free();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read the activation token when present. An unreadable token or machine
|
||||||
|
// id resolves to free, matching the strict posture of `current_edition`
|
||||||
|
// before this refactor.
|
||||||
|
let mut activation_token: Option<String> = None;
|
||||||
|
let mut local_machine_id = String::new();
|
||||||
|
if let Ok(act_path) = activation_path(app) {
|
||||||
|
if act_path.exists() {
|
||||||
|
let Ok(token) = fs::read_to_string(&act_path) else {
|
||||||
|
return free();
|
||||||
|
};
|
||||||
|
let Ok(local_id) = machine_id_internal() else {
|
||||||
|
return free();
|
||||||
|
};
|
||||||
|
activation_token = Some(token);
|
||||||
|
local_machine_id = local_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_license_entitlements(
|
||||||
|
&key,
|
||||||
|
activation_token.as_deref(),
|
||||||
|
&local_machine_id,
|
||||||
|
&decoding_key,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Edition-only view of [`current_entitlements`], used by `get_edition` and any
|
||||||
|
/// caller that does not need the feature overrides.
|
||||||
|
pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
|
||||||
|
current_entitlements(app).0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the HMAC-verified account cache to check for an active Premium
|
/// Read the HMAC-verified account cache to check for an active Premium
|
||||||
|
|
@ -677,4 +743,118 @@ mod tests {
|
||||||
// Sanity check that the production PEM constant is well-formed.
|
// Sanity check that the production PEM constant is well-formed.
|
||||||
assert!(embedded_decoding_key().is_ok());
|
assert!(embedded_decoding_key().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Entitlements resolution (edition + signed features, machine binding) =================
|
||||||
|
|
||||||
|
fn base_license_with_features(enc: &EncodingKey, features: Vec<String>) -> String {
|
||||||
|
let claims = LicenseClaims {
|
||||||
|
sub: "user@example.com".to_string(),
|
||||||
|
iss: "lacompagniemaximus.com".to_string(),
|
||||||
|
iat: now(),
|
||||||
|
exp: now() + 86400,
|
||||||
|
edition: EDITION_BASE.to_string(),
|
||||||
|
features,
|
||||||
|
machine_limit: 3,
|
||||||
|
};
|
||||||
|
let jwt = make_token(enc, &claims);
|
||||||
|
format!("{}{}", KEY_PREFIX_BASE, jwt)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activation_for(enc: &EncodingKey, machine_id: &str) -> String {
|
||||||
|
let claims = ActivationClaims {
|
||||||
|
sub: "license-id".to_string(),
|
||||||
|
iat: now(),
|
||||||
|
exp: now() + 86400,
|
||||||
|
machine_id: machine_id.to_string(),
|
||||||
|
};
|
||||||
|
make_token(enc, &claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn machine_match_keeps_edition_and_features() {
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let key = base_license_with_features(&enc, vec!["balance".to_string()]);
|
||||||
|
let token = activation_for(&enc, "machine-A");
|
||||||
|
let (edition, features) =
|
||||||
|
resolve_license_entitlements(&key, Some(&token), "machine-A", &dec);
|
||||||
|
assert_eq!(edition, EDITION_BASE);
|
||||||
|
assert_eq!(features, vec!["balance".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn machine_mismatch_downgrades_to_free_and_drops_features() {
|
||||||
|
// CWE-863: a copied license.key + activation.token still carries its
|
||||||
|
// signed features[] — they must vanish together with the downgrade.
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let key = base_license_with_features(&enc, vec!["balance".to_string()]);
|
||||||
|
let token = activation_for(&enc, "machine-A");
|
||||||
|
let (edition, features) =
|
||||||
|
resolve_license_entitlements(&key, Some(&token), "machine-B", &dec);
|
||||||
|
assert_eq!(edition, EDITION_FREE);
|
||||||
|
assert!(
|
||||||
|
features.is_empty(),
|
||||||
|
"signed features must not survive the machine-binding downgrade"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_activation_token_keeps_edition_and_features() {
|
||||||
|
// Graceful pre-activation state: no token yet, the license still counts.
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let key = base_license_with_features(&enc, vec!["balance".to_string()]);
|
||||||
|
let (edition, features) = resolve_license_entitlements(&key, None, "machine-A", &dec);
|
||||||
|
assert_eq!(edition, EDITION_BASE);
|
||||||
|
assert_eq!(features, vec!["balance".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_license_resolves_free_without_features() {
|
||||||
|
let (_enc, dec) = default_keys();
|
||||||
|
let (edition, features) =
|
||||||
|
resolve_license_entitlements("SR-BASE-not.a.jwt", None, "machine-A", &dec);
|
||||||
|
assert_eq!(edition, EDITION_FREE);
|
||||||
|
assert!(features.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Dev override =========================================================================
|
||||||
|
|
||||||
|
/// The dev override must be dead code in normal builds: even with
|
||||||
|
/// SR_DEV_EDITION set, nothing reads it when the `dev-override` Cargo
|
||||||
|
/// feature is off (CWE-489 — a release binary must never honor the env
|
||||||
|
/// var). Env-var note: under this feature-off build NO code path reads
|
||||||
|
/// SR_DEV_EDITION, so setting it here cannot race with parallel tests.
|
||||||
|
#[cfg(not(feature = "dev-override"))]
|
||||||
|
#[test]
|
||||||
|
fn sr_dev_edition_has_no_effect_when_feature_off() {
|
||||||
|
std::env::set_var("SR_DEV_EDITION", "premium");
|
||||||
|
assert_eq!(dev_override_edition(), None);
|
||||||
|
std::env::remove_var("SR_DEV_EDITION");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Companion coverage for `cargo test --features dev-override` (not part of
|
||||||
|
// the normal CI run). SR_DEV_EDITION is process-global and cargo test runs
|
||||||
|
// tests on parallel threads, so every env manipulation serializes on a lock.
|
||||||
|
#[cfg(feature = "dev-override")]
|
||||||
|
mod dev_override_on {
|
||||||
|
use super::super::*;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sr_dev_edition_forces_edition() {
|
||||||
|
let _guard = ENV_LOCK.lock().unwrap();
|
||||||
|
std::env::set_var("SR_DEV_EDITION", "premium");
|
||||||
|
assert_eq!(dev_override_edition(), Some("premium".to_string()));
|
||||||
|
std::env::remove_var("SR_DEV_EDITION");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sr_dev_edition_unknown_value_ignored() {
|
||||||
|
let _guard = ENV_LOCK.lock().unwrap();
|
||||||
|
std::env::set_var("SR_DEV_EDITION", "enterprise");
|
||||||
|
assert_eq!(dev_override_edition(), None);
|
||||||
|
std::env::remove_var("SR_DEV_EDITION");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue