diff --git a/.env.example b/.env.example index f88fe21..dfac46b 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,7 @@ # This file contains example environment variables for configuring storify. # Copy this file to .env and fill in your actual values. # -# Supported storage providers: oss, s3, minio, fs, hdfs +# Supported storage providers: oss, s3, minio, cos, fs, hdfs, azblob # ============================================================================= # ============================================================================= @@ -13,7 +13,7 @@ # ============================================================================= # Storage provider type (required) -# Options: oss, s3, minio, fs, hdfs +# Options: oss, s3, minio, cos, fs, hdfs, azblob # Default: oss STORAGE_PROVIDER=oss diff --git a/README.md b/README.md index 76dc900..3c0c5d1 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,18 @@ storify config list storify config set myprofile ``` +### Temporary Config Cache (TTL) + +Use an encrypted, TTL-based temporary cache when you don't want to create a named profile: + +```bash +# Set a temporary config (default TTL 24h) +storify config create --temp --provider cos --bucket my-bucket + +# Clear temporary config +storify config temp clear +``` + ### Using Environment Variables Set your storage provider and credentials: diff --git a/docs/config-providers.md b/docs/config-providers.md index 710a7f0..c120d0e 100644 --- a/docs/config-providers.md +++ b/docs/config-providers.md @@ -1,6 +1,12 @@ # Configuration and Providers -Storify supports two configuration styles: encrypted profiles and environment variables. Environment variables always win over stored profile values. +Storify supports encrypted profiles and environment variables. It also supports an encrypted temporary config cache with a TTL. + +## Resolution order (highest to lowest) +- `--profile ` (explicit profile selection) +- temporary config cache (if set and not expired) +- environment variables (`STORAGE_PROVIDER` + provider-specific variables) +- default profile (from profile store) ## Profiles (recommended) - Create interactively: `storify config create myprofile` @@ -32,6 +38,6 @@ Storify supports two configuration styles: encrypted profiles and environment va - COS, HDFS, Azblob: No (not supported) ## Security -- Profile store is encrypted with AES-256-GCM. +- Profile store is encrypted with ChaCha20Poly1305 (field-level encryption). - On Unix, profile store permissions are set to 0600. - Writes are atomic; a `.bak` backup is created before modifying the store. diff --git a/docs/usage.md b/docs/usage.md index 1195e3d..dc50b45 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -47,3 +47,13 @@ This page lists the common Storify CLI commands with short, copy-pastable exampl - `-d`: tree depth - `-f`: force (skip confirmations where applicable) - `--json` / `--raw`: structured output for `stat` + +## Temporary config cache +Use an encrypted, TTL-based temporary cache to switch providers quickly without creating a named profile: + +- Set temporary config (default TTL 24h): `storify config create --temp --provider cos --bucket my-bucket --access-key-id ... --access-key-secret ...` +- Override TTL: `storify config create --temp --ttl 4h --provider s3 --bucket my-bucket` +- Show current temp: `storify config temp show` +- Clear temp: `storify config temp clear` + +`--profile ` always overrides the temporary cache for a single command. diff --git a/src/cli/config.rs b/src/cli/config.rs index 954fb45..7c69ca1 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -5,14 +5,17 @@ use crate::config::{ spec::{ProviderSpec, Requirement, provider_spec}, }; use crate::error::{Error, Result}; -use std::env; use std::str::FromStr; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::runtime::Handle; use tokio::task; use super::{ context::CliContext, - entry::{ConfigCommand, CreateArgs, DeleteArgs, ListArgs, SetArgs, ShowArgs}, + entry::{ + ConfigCommand, CreateArgs, DeleteArgs, ListArgs, SetArgs, ShowArgs, TempClearArgs, + TempCommand, TempShowArgs, + }, prompts::Prompt, }; @@ -84,6 +87,7 @@ pub fn execute(command: &ConfigCommand, ctx: &CliContext) -> Result<()> { ConfigCommand::Set(args) => set_default_profile(args, ctx), ConfigCommand::List(args) => list_profiles(args, ctx), ConfigCommand::Delete(args) => delete_profile(args, ctx), + ConfigCommand::Temp(cmd) => temp_command(cmd, ctx), } } @@ -124,8 +128,6 @@ fn show_command(args: &ShowArgs, ctx: &CliContext) -> Result<()> { } fn build_source_hint(source: Option, resolved: &ResolvedConfig) -> Option { - let has_env_provider = env::var("STORAGE_PROVIDER").is_ok(); - match source { Some(ConfigSource::ExplicitProfile) => { let profile = resolved.profile.as_deref().unwrap_or("unknown"); @@ -133,50 +135,78 @@ fn build_source_hint(source: Option, resolved: &ResolvedConfig) -> } Some(ConfigSource::DefaultProfile) => { let profile = resolved.profile.as_deref().unwrap_or("unknown"); - let mut hint = format!("default profile '{}'", profile); - - if has_env_provider { - hint.push_str("\n# (overriding STORAGE_PROVIDER environment variable)"); - hint.push_str("\n# Tip: Run 'config set --clear' to use environment variables"); + Some(format!("default profile '{}'", profile)) + } + Some(ConfigSource::Environment) => Some("environment variables".to_string()), + Some(ConfigSource::TempCache) => { + let mut hint = "temporary config cache".to_string(); + if let Some(expires_at) = resolved.temp_expires_at_unix { + hint.push_str(&format!(" (expires {})", format_expires_hint(expires_at))); } - Some(hint) } - Some(ConfigSource::Environment) => Some("environment variables".to_string()), None => None, } } +fn format_expires_hint(expires_at_unix: u64) -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|_| Duration::from_secs(0)) + .as_secs(); + if expires_at_unix <= now { + return "now".to_string(); + } + let remain = expires_at_unix - now; + if remain < 60 { + return format!("in {}s", remain); + } + if remain < 60 * 60 { + return format!("in {}m", remain / 60); + } + if remain < 24 * 60 * 60 { + return format!("in {}h", remain / 3600); + } + format!("in {}d", remain / (24 * 3600)) +} + fn create_profile(args: &CreateArgs, ctx: &CliContext) -> Result<()> { let mut store = open_profile_store(ctx)?; let mut session = PromptSession::new(); - let mut name = match &args.name { - Some(name) => name.clone(), - None => { - println!("Enter a name for this profile."); - session.input_required(ctx, "Profile name", false)? - } - }; - - if name.trim().is_empty() { - return Err(Error::InvalidArgument { - message: "Profile name cannot be empty.".into(), - }); - } - name = name.trim().to_string(); + let temp_mode = args.temp; + let name = if temp_mode { + args.name.clone().unwrap_or_else(|| "temp".to_string()) + } else { + let mut name = match &args.name { + Some(name) => name.clone(), + None => { + println!("Enter a name for this profile."); + session.input_required(ctx, "Profile name", false)? + } + }; - if store.profile(&name).is_some() && !args.force { - let overwrite = session.confirm( - ctx, - &format!("Profile '{}' exists. Overwrite?", name), - false, - )?; - if !overwrite { - println!("Operation cancelled."); - return Ok(()); + if name.trim().is_empty() { + return Err(Error::InvalidArgument { + message: "Profile name cannot be empty.".into(), + }); + } + name = name.trim().to_string(); + + if store.profile(&name).is_some() && !args.force { + let overwrite = session.confirm( + ctx, + &format!("Profile '{}' exists. Overwrite?", name), + false, + )?; + if !overwrite { + println!("Operation cancelled."); + return Ok(()); + } } - } + + name + }; let provider_input = match &args.provider { Some(provider) => provider.clone(), @@ -273,18 +303,116 @@ fn create_profile(args: &CreateArgs, ctx: &CliContext) -> Result<()> { prepare_storage_config(&mut config)?; - let mut make_default = args.make_default; - if !make_default && session.used { - make_default = - session.confirm(ctx, &format!("Set '{name}' as the default profile?"), false)?; + let stored = StoredProfile::from_config(&config); + if temp_mode { + let ttl = parse_ttl(&args.ttl)?; + store.set_temp_profile(stored, ttl)?; + println!("Temporary config cache saved to {}", store.path().display()); + println!("Name hint: {}", name); + println!("TTL: {}", args.ttl); + println!("Tip: use `storify --profile ` to override temporary cache"); + } else { + let mut make_default = args.make_default; + if !make_default && session.used { + make_default = + session.confirm(ctx, &format!("Set '{name}' as the default profile?"), false)?; + } + + store.save_profile(name.clone(), stored, make_default)?; + println!("Profile '{}' saved to {}", name, store.path().display()); + if make_default { + println!("'{}' marked as default.", name); + } } + Ok(()) +} - let stored = StoredProfile::from_config(&config); - store.save_profile(name.clone(), stored, make_default)?; - println!("Profile '{}' saved to {}", name, store.path().display()); - if make_default { - println!("'{}' marked as default.", name); +fn parse_ttl(input: &str) -> Result { + let s = input.trim(); + if s.is_empty() { + return Err(Error::InvalidArgument { + message: "ttl cannot be empty".to_string(), + }); + } + if s.chars().all(|c| c.is_ascii_digit()) { + let secs: u64 = s.parse().map_err(|_| Error::InvalidArgument { + message: format!("invalid ttl: {s}"), + })?; + return Ok(std::time::Duration::from_secs(secs.max(1))); } + let (num, unit) = s.split_at(s.len().saturating_sub(1)); + let n: u64 = num.parse().map_err(|_| Error::InvalidArgument { + message: format!("invalid ttl: {s}"), + })?; + let secs = match unit { + "s" => n, + "m" => n.saturating_mul(60), + "h" => n.saturating_mul(60 * 60), + "d" => n.saturating_mul(24 * 60 * 60), + _ => { + return Err(Error::InvalidArgument { + message: format!("invalid ttl unit: {unit} (expected s|m|h|d)"), + }); + } + }; + Ok(std::time::Duration::from_secs(secs.max(1))) +} + +fn temp_command(cmd: &TempCommand, ctx: &CliContext) -> Result<()> { + match cmd { + TempCommand::Show(args) => show_temp(args, ctx), + TempCommand::Clear(args) => clear_temp(args, ctx), + } +} + +fn show_temp(args: &TempShowArgs, ctx: &CliContext) -> Result<()> { + let store = open_profile_store(ctx)?; + let Some(profile) = store.temp_profile() else { + println!("No temporary config cache set."); + return Ok(()); + }; + + let credential_mode = if args.show_secrets { + CredentialMode::PlainText + } else { + CredentialMode::Redacted + }; + + if let Some(expires_at) = store.temp_expires_at_unix() { + println!( + "# Temporary config cache (expires {})\n", + format_expires_hint(expires_at) + ); + } else { + println!("# Temporary config cache\n"); + } + + let config = profile.clone().into_config()?; + print_config(&config, "", credential_mode); + Ok(()) +} + +fn clear_temp(args: &TempClearArgs, ctx: &CliContext) -> Result<()> { + let mut store = open_profile_store(ctx)?; + if store.temp_profile().is_none() { + println!("No temporary config cache set."); + return Ok(()); + } + + if !args.force { + ctx.ensure_interactive("clear temporary config cache")?; + let prompt = ctx.prompt(); + let confirmed = task::block_in_place(|| { + Handle::current().block_on(prompt.confirm("Clear temporary config cache?", false)) + })?; + if !confirmed { + println!("Operation cancelled."); + return Ok(()); + } + } + + let _ = store.clear_temp_profile()?; + println!("Temporary config cache cleared."); Ok(()) } @@ -313,27 +441,13 @@ fn print_provider_help(provider: StorageProvider, spec: ProviderSpec) { fn set_default_profile(args: &SetArgs, ctx: &CliContext) -> Result<()> { let mut store = open_profile_store(ctx)?; - let has_env_provider = env::var("STORAGE_PROVIDER").is_ok(); if args.clear { store.set_default_profile(None)?; println!("✓ Default profile cleared"); - - if has_env_provider { - println!("ℹ Will now use STORAGE_PROVIDER environment variable"); - } else { - println!( - "ℹ No default profile set. You'll need to provide --profile or set environment variables" - ); - } } else if let Some(name) = &args.name { store.set_default_profile(Some(name.clone()))?; println!("✓ Default profile set to '{}'", name); - - if has_env_provider { - println!("ℹ This profile will override STORAGE_PROVIDER environment variable"); - println!("ℹ To temporarily use env vars, run: config set --clear"); - } } Ok(()) } diff --git a/src/cli/entry.rs b/src/cli/entry.rs index df8fbed..a48eca2 100644 --- a/src/cli/entry.rs +++ b/src/cli/entry.rs @@ -98,13 +98,16 @@ pub enum ConfigCommand { /// Show configuration information Show(ShowArgs), /// Create or update a profile in the profile store - Create(CreateArgs), + Create(Box), /// Mutate configuration settings (e.g. default profile) Set(SetArgs), /// List profiles in the profile store List(ListArgs), /// Delete a profile from the profile store Delete(DeleteArgs), + /// Manage temporary config cache (encrypted, TTL-based) + #[command(subcommand)] + Temp(TempCommand), } #[derive(ClapArgs, Debug, Clone)] @@ -159,6 +162,36 @@ pub struct CreateArgs { /// Mark the profile as default after creation #[arg(long = "make-default")] pub make_default: bool, + + /// Save as temporary config cache instead of a named profile + #[arg(long)] + pub temp: bool, + + /// Time-to-live for temporary cache (e.g. 30m, 24h, 7d). Default: 24h + #[arg(long, default_value = "24h")] + pub ttl: String, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum TempCommand { + /// Show temporary config cache (if present and not expired) + Show(TempShowArgs), + /// Clear temporary config cache + Clear(TempClearArgs), +} + +#[derive(ClapArgs, Debug, Clone)] +pub struct TempShowArgs { + /// Show secrets in plaintext (access_key_id, access_key_secret). Default: redacted + #[arg(long)] + pub show_secrets: bool, +} + +#[derive(ClapArgs, Debug, Clone)] +pub struct TempClearArgs { + /// Skip confirmation prompt + #[arg(long)] + pub force: bool, } #[derive(ClapArgs, Debug, Clone)] diff --git a/src/config/loader.rs b/src/config/loader.rs index 8a60f88..8b14306 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -22,6 +22,7 @@ pub enum ConfigSource { ExplicitProfile, DefaultProfile, Environment, + TempCache, } #[derive(Debug, Clone, Default)] @@ -32,6 +33,7 @@ pub struct ResolvedConfig { pub available_profiles: Vec, pub default_profile: Option, pub source: Option, + pub temp_expires_at_unix: Option, } fn env_value(key: &str) -> Option { @@ -286,6 +288,17 @@ fn try_load_env(resolved: &mut ResolvedConfig) -> bool { } } +fn try_load_temp(store: &ProfileStore, resolved: &mut ResolvedConfig) -> Result { + let Some(profile) = store.temp_profile() else { + return Ok(false); + }; + let config = profile.clone().into_config()?; + resolved.storage = Some(config); + resolved.source = Some(ConfigSource::TempCache); + resolved.temp_expires_at_unix = store.temp_expires_at_unix(); + Ok(true) +} + pub fn resolve(request: ConfigRequest) -> Result { let mut resolved = ResolvedConfig::default(); let store = open_and_populate_store(&request, &mut resolved)?; @@ -306,6 +319,16 @@ pub fn resolve(request: ConfigRequest) -> Result { return Ok(resolved); } + if let Some(store) = store.as_ref() + && try_load_temp(store, &mut resolved)? + { + return Ok(resolved); + } + + if try_load_env(&mut resolved) { + return Ok(resolved); + } + if let Some(store) = store.as_ref() && let Some(default_name) = store.default_profile() { @@ -318,10 +341,6 @@ pub fn resolve(request: ConfigRequest) -> Result { return Ok(resolved); } - if try_load_env(&mut resolved) { - return Ok(resolved); - } - if request.require_storage { ensure_interactive(&request, "Resolving configuration from environment")?; return Err(Error::NoConfiguration { diff --git a/src/config/profile_store.rs b/src/config/profile_store.rs index fc9005f..9f40110 100644 --- a/src/config/profile_store.rs +++ b/src/config/profile_store.rs @@ -17,6 +17,7 @@ use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; use std::str::FromStr; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use uuid::Uuid; #[cfg(unix)] @@ -86,6 +87,15 @@ struct ProfileStoreFile { default: Option, #[serde(default)] profiles: BTreeMap, + #[serde(default)] + temp: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct TempProfileEntry { + created_at_unix: u64, + expires_at_unix: u64, + profile: StoredProfile, } impl ProfileStoreFile { @@ -100,6 +110,15 @@ impl ProfileStoreFile { self.default = None; } } + + fn normalize_temp(&mut self) { + let now = now_unix(); + if let Some(t) = &self.temp + && t.expires_at_unix <= now + { + self.temp = None; + } + } } #[derive(Debug, Clone, Default)] @@ -297,6 +316,44 @@ impl ProfileStore { self.file.default.as_deref() } + pub fn temp_profile(&self) -> Option<&StoredProfile> { + let now = now_unix(); + self.file + .temp + .as_ref() + .filter(|t| t.expires_at_unix > now) + .map(|t| &t.profile) + } + + pub fn temp_expires_at_unix(&self) -> Option { + let now = now_unix(); + self.file + .temp + .as_ref() + .filter(|t| t.expires_at_unix > now) + .map(|t| t.expires_at_unix) + } + + pub fn set_temp_profile(&mut self, profile: StoredProfile, ttl: Duration) -> Result<()> { + let now = now_unix(); + let expires_at_unix = now.saturating_add(ttl.as_secs().max(1)); + self.file.temp = Some(TempProfileEntry { + created_at_unix: now, + expires_at_unix, + profile, + }); + self.persist() + } + + pub fn clear_temp_profile(&mut self) -> Result { + if self.file.temp.is_none() { + return Ok(false); + } + self.file.temp = None; + self.persist()?; + Ok(true) + } + pub fn save_profile( &mut self, name: String, @@ -342,11 +399,15 @@ impl ProfileStore { fn persist(&mut self) -> Result<()> { let mut payload = self.file.clone(); payload.normalize_default(); + payload.normalize_temp(); let key = self.encryption.key(); let salt = self.encryption.salt(); encrypt_all_profiles(&mut payload.profiles, key)?; + if let Some(t) = payload.temp.as_mut() { + encrypt_profile_secrets(&mut t.profile, key)?; + } let serialized = toml::to_string_pretty(&payload).map_err(|source| Error::ProfileStoreSerialize { @@ -413,11 +474,14 @@ impl ProfileStore { let key = derive_master_key(&password, &salt)?; for profile in file.profiles.values_mut() { - decrypt_sensitive_field(&mut profile.access_key_id, &key)?; - decrypt_sensitive_field(&mut profile.access_key_secret, &key)?; + decrypt_profile_secrets(profile, &key)?; + } + if let Some(t) = file.temp.as_mut() { + decrypt_profile_secrets(&mut t.profile, &key)?; } file.normalize_default(); + file.normalize_temp(); let metadata = EncryptionMetadata::new(key, salt); Ok((file, metadata)) } @@ -438,21 +502,28 @@ fn encrypt_all_profiles( key: &[u8; 32], ) -> Result<()> { for profile in profiles.values_mut() { - // Encrypt access_key_id - if let Some(value) = &profile.access_key_id { - profile.access_key_id = Some(encrypt_field(value, key)?); - } + encrypt_profile_secrets(profile, key)?; + } - // Encrypt access_key_secret - if let Some(value) = &profile.access_key_secret { - profile.access_key_secret = Some(encrypt_field(value, key)?); - } + Ok(()) +} + +fn encrypt_profile_secrets(profile: &mut StoredProfile, key: &[u8; 32]) -> Result<()> { + if let Some(value) = profile.access_key_id.as_deref() { + profile.access_key_id = Some(encrypt_field(value, key)?); + } + if let Some(value) = profile.access_key_secret.as_deref() { + profile.access_key_secret = Some(encrypt_field(value, key)?); } + Ok(()) +} +fn decrypt_profile_secrets(profile: &mut StoredProfile, key: &[u8; 32]) -> Result<()> { + decrypt_sensitive_field(&mut profile.access_key_id, key)?; + decrypt_sensitive_field(&mut profile.access_key_secret, key)?; Ok(()) } -/// Decrypt sensitive field fn decrypt_sensitive_field(field: &mut Option, key: &[u8; 32]) -> Result<()> { if let Some(encrypted) = field.as_mut() && let Some(decrypted) = decrypt_field(encrypted, key)? @@ -462,6 +533,13 @@ fn decrypt_sensitive_field(field: &mut Option, key: &[u8; 32]) -> Result Ok(()) } +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|_| Duration::from_secs(0)) + .as_secs() +} + /// RAII guard for temporary files (auto-cleanup on drop) struct TempFile { path: PathBuf, @@ -606,3 +684,70 @@ fn backup_path(path: &Path) -> PathBuf { .unwrap_or("profiles.toml"); path.with_file_name(format!("{file_name}.bak")) } + +#[cfg(test)] +mod tests { + use super::*; + use secrecy::SecretString; + + fn test_password() -> SecretString { + SecretString::new("test-password".into()) + } + + fn test_config(provider: StorageProvider, bucket: &str) -> StorageConfig { + match provider { + StorageProvider::Oss => StorageConfig::oss(bucket.to_string()), + StorageProvider::S3 => StorageConfig::s3(bucket.to_string()), + StorageProvider::Cos => StorageConfig::cos(bucket.to_string()), + StorageProvider::Fs => StorageConfig::fs(Some("./tmp".to_string())), + StorageProvider::Hdfs => { + StorageConfig::hdfs(Some("nn".to_string()), Some("/".to_string())) + } + StorageProvider::Azblob => StorageConfig::azblob(bucket.to_string()), + } + } + + #[test] + fn temp_profile_roundtrip_and_expiry_check() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("profiles.toml"); + + let mut store = + ProfileStore::open_with_password(Some(path.clone()), Some(test_password())).unwrap(); + + let mut cfg = test_config(StorageProvider::Oss, "b1"); + cfg.access_key_id = Some("id".to_string()); + cfg.access_key_secret = Some("secret".to_string()); + let stored = StoredProfile::from_config(&cfg); + + store + .set_temp_profile(stored.clone(), Duration::from_secs(3600)) + .unwrap(); + + let temp = store.temp_profile().expect("temp profile"); + assert_eq!(temp.provider, stored.provider); + assert_eq!(temp.bucket, stored.bucket); + + // Force expiry without waiting. + store.file.temp.as_mut().unwrap().expires_at_unix = now_unix().saturating_sub(1); + assert!(store.temp_profile().is_none()); + } + + #[test] + fn clear_temp_profile_removes_entry() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("profiles.toml"); + let mut store = + ProfileStore::open_with_password(Some(path.clone()), Some(test_password())).unwrap(); + + let cfg = test_config(StorageProvider::S3, "b2"); + store + .set_temp_profile(StoredProfile::from_config(&cfg), Duration::from_secs(3600)) + .unwrap(); + assert!(store.temp_profile().is_some()); + + assert!(store.clear_temp_profile().unwrap()); + assert!(store.temp_profile().is_none()); + assert!(!store.clear_temp_profile().unwrap()); + } +} diff --git a/src/error.rs b/src/error.rs index a3070f3..bd63967 100644 --- a/src/error.rs +++ b/src/error.rs @@ -152,6 +152,7 @@ pub enum Error { "No configuration resolves. Available profiles: {profiles}. Hint: run `storify config` or supply --profile" ))] NoConfiguration { profiles: String }, + // Reserved for future extension. } impl From for Error { diff --git a/src/storage/operations/grep.rs b/src/storage/operations/grep.rs index 5e6c413..24a7581 100644 --- a/src/storage/operations/grep.rs +++ b/src/storage/operations/grep.rs @@ -205,7 +205,7 @@ impl OpenDalGreper { // Optimize ASCII fast-path for case-insensitive checks without allocations let matched = if opts.ignore_case { if line.is_ascii() && opts.needle.is_ascii() { - // opts.needle 已在外层按 ignore_case 预小写,这里仅对 haystack 做无分配按位比较 + // opts.needle is pre-lowercased; compare haystack in-place without allocations. Self::ascii_contains_case_insensitive(line.as_bytes(), opts.needle.as_bytes()) } else { line.to_lowercase().contains(opts.needle)