Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@
# 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
# =============================================================================

# =============================================================================
# STORAGE PROVIDER CONFIGURATION
# =============================================================================

# Storage provider type (required)
# Options: oss, s3, minio, fs, hdfs
# Options: oss, s3, minio, cos, fs, hdfs, azblob
# Default: oss
STORAGE_PROVIDER=oss

Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 8 additions & 2 deletions docs/config-providers.md
Original file line number Diff line number Diff line change
@@ -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 <name>` (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`
Expand Down Expand Up @@ -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.
10 changes: 10 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` always overrides the temporary cache for a single command.
230 changes: 172 additions & 58 deletions src/cli/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -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),
}
}

Expand Down Expand Up @@ -124,59 +128,85 @@ fn show_command(args: &ShowArgs, ctx: &CliContext) -> Result<()> {
}

fn build_source_hint(source: Option<ConfigSource>, resolved: &ResolvedConfig) -> Option<String> {
let has_env_provider = env::var("STORAGE_PROVIDER").is_ok();

match source {
Some(ConfigSource::ExplicitProfile) => {
let profile = resolved.profile.as_deref().unwrap_or("unknown");
Some(format!("--profile '{}'", profile))
}
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(),
Expand Down Expand Up @@ -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 <name>` 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<std::time::Duration> {
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(())
}

Expand Down Expand Up @@ -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(())
}
Expand Down
Loading