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
40 changes: 31 additions & 9 deletions crates/bashkit/src/builtins/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,28 @@ use crate::interpreter::ExecResult;
/// %n Newline
/// %t Tab
/// %% Literal %
pub struct Date;
/// THREAT[TM-INF-018]: Supports a fixed epoch to prevent leaking real host time.
pub struct Date {
/// Fixed UTC epoch for virtualized time. None = use real system clock.
fixed_epoch: Option<DateTime<Utc>>,
}

impl Date {
pub fn new() -> Self {
Self { fixed_epoch: None }
}

/// Create a Date builtin with a fixed epoch (for sandboxing).
pub fn with_fixed_epoch(epoch: DateTime<Utc>) -> Self {
Self {
fixed_epoch: Some(epoch),
}
}

fn now(&self) -> DateTime<Utc> {
self.fixed_epoch.unwrap_or_else(Utc::now)
}
}

/// Validate a strftime format string.
/// Returns Ok(()) if valid, or an error message describing the issue.
Expand Down Expand Up @@ -74,8 +95,7 @@ fn strip_surrounding_quotes(s: &str) -> &str {
}

/// Parse a base date expression (no compound modifiers).
fn parse_base_date(s: &str) -> std::result::Result<DateTime<Utc>, String> {
let now = Utc::now();
fn parse_base_date(s: &str, now: DateTime<Utc>) -> std::result::Result<DateTime<Utc>, String> {
let lower = s.to_lowercase();

// Epoch timestamp: @1234567890
Expand Down Expand Up @@ -135,7 +155,7 @@ fn parse_base_date(s: &str) -> std::result::Result<DateTime<Utc>, String> {
/// Supports compound expressions (base ± modifier):
/// "2024-01-15 + 30 days", "yesterday - 2 hours",
/// "@1700000000 + 1 week", "2024-01-15 - 1 month"
fn parse_date_string(s: &str) -> std::result::Result<DateTime<Utc>, String> {
fn parse_date_string(s: &str, now: DateTime<Utc>) -> std::result::Result<DateTime<Utc>, String> {
let s = strip_surrounding_quotes(s.trim());

// Try compound expression: <base> [+-] <N unit(s)>
Expand All @@ -156,15 +176,15 @@ fn parse_date_string(s: &str) -> std::result::Result<DateTime<Utc>, String> {
// Use original case for base string to handle epoch (@N)
// and ISO dates correctly.
let orig_base = s[..base_match.end()].trim();
if let Ok(base_dt) = parse_base_date(orig_base) {
if let Ok(base_dt) = parse_base_date(orig_base, now) {
let offset = unit_duration(unit, sign * n);
return Ok(base_dt + offset);
}
}
}
}

parse_base_date(s)
parse_base_date(s, now)
}

/// Parse relative date expressions like "30 days ago", "+2 weeks", "-1 month"
Expand Down Expand Up @@ -321,13 +341,15 @@ impl Builtin for Date {
}

// Get the datetime to format
// THREAT[TM-INF-018]: Use virtual time if configured
let now = self.now();
let dt_utc = if let Some(ref ds) = date_str {
match parse_date_string(ds) {
match parse_date_string(ds, now) {
Ok(dt) => dt,
Err(e) => return Ok(ExecResult::err(format!("{}\n", e), 1)),
}
} else {
Utc::now()
now
};

// Handle -R (RFC 2822) output
Expand Down Expand Up @@ -417,7 +439,7 @@ mod tests {
git_client: None,
};

Date.execute(ctx).await.unwrap()
Date::new().execute(ctx).await.unwrap()
}

#[tokio::test]
Expand Down
20 changes: 14 additions & 6 deletions crates/bashkit/src/fs/memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,8 @@ impl InMemoryFs {
dir_count += 1;
}
FsEntry::Symlink { .. } => {
// Symlinks don't count toward file count or size
// THREAT[TM-DOS-045]: Symlinks count toward file count
file_count += 1;
}
}
}
Expand Down Expand Up @@ -1148,6 +1149,15 @@ impl FileSystem for InMemoryFs {
.remove(&from)
.ok_or_else(|| IoError::new(ErrorKind::NotFound, "not found"))?;

// THREAT[TM-DOS-048]: Reject renaming a file over a directory (POSIX requirement)
if matches!(&entry, FsEntry::File { .. } | FsEntry::Symlink { .. })
&& matches!(entries.get(&to), Some(FsEntry::Directory { .. }))
{
// Put back the source entry
entries.insert(from, entry);
return Err(IoError::other("cannot rename file over directory").into());
}

entries.insert(to, entry);
Ok(())
}
Expand All @@ -1168,15 +1178,13 @@ impl FileSystem for InMemoryFs {
.cloned()
.ok_or_else(|| IoError::new(ErrorKind::NotFound, "not found"))?;

// Check write limits before creating the copy
// THREAT[TM-DOS-047]: Always check write limits, even on overwrite.
// check_write_limits handles the delta calculation for existing files.
let entry_size = match &entry {
FsEntry::File { content, .. } => content.len() as u64,
_ => 0,
};
let is_new = !entries.contains_key(&to);
if is_new {
self.check_write_limits(&entries, &to, entry_size as usize)?;
}
self.check_write_limits(&entries, &to, entry_size as usize)?;

entries.insert(to, entry);
Ok(())
Expand Down
8 changes: 8 additions & 0 deletions crates/bashkit/src/fs/overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -681,8 +681,16 @@ impl FileSystem for OverlayFs {
}

async fn symlink(&self, target: &Path, link: &Path) -> Result<()> {
// THREAT[TM-DOS-045]: Validate path and enforce limits like other write methods
self.limits
.validate_path(link)
.map_err(|e| IoError::other(e.to_string()))?;

let link = Self::normalize_path(link);

// Check write limits (symlinks count toward file count)
self.check_write_limits(0)?;

// Remove any whiteout
self.remove_whiteout(&link);

Expand Down
51 changes: 45 additions & 6 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ impl Interpreter {

/// Create a new interpreter with the given filesystem.
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
Self::with_config(fs, None, None, HashMap::new())
Self::with_config(fs, None, None, None, HashMap::new())
}

/// Create a new interpreter with custom username, hostname, and builtins.
Expand All @@ -327,6 +327,7 @@ impl Interpreter {
fs: Arc<dyn FileSystem>,
username: Option<String>,
hostname: Option<String>,
fixed_epoch: Option<i64>,
custom_builtins: HashMap<String, Box<dyn Builtin>>,
) -> Self {
let mut builtins: HashMap<String, Box<dyn Builtin>> = HashMap::new();
Expand Down Expand Up @@ -408,7 +409,18 @@ impl Interpreter {
builtins.insert("uniq".to_string(), Box::new(builtins::Uniq));
builtins.insert("cut".to_string(), Box::new(builtins::Cut));
builtins.insert("tr".to_string(), Box::new(builtins::Tr));
builtins.insert("date".to_string(), Box::new(builtins::Date));
// THREAT[TM-INF-018]: Use fixed epoch if configured, else real clock
builtins.insert(
"date".to_string(),
Box::new(if let Some(epoch) = fixed_epoch {
use chrono::DateTime;
builtins::Date::with_fixed_epoch(
DateTime::from_timestamp(epoch, 0).unwrap_or_default(),
)
} else {
builtins::Date::new()
}),
);
builtins.insert("wait".to_string(), Box::new(builtins::Wait));
builtins.insert("curl".to_string(), Box::new(builtins::Curl));
builtins.insert("wget".to_string(), Box::new(builtins::Wget));
Expand Down Expand Up @@ -7995,7 +8007,18 @@ impl Interpreter {
/// Check if a string contains glob characters
/// Expand brace patterns like {a,b,c} or {1..5}
/// Returns a Vec of expanded strings, or a single-element Vec if no braces
/// THREAT[TM-DOS-042]: Cap total expansion count to prevent combinatorial OOM.
fn expand_braces(&self, s: &str) -> Vec<String> {
const MAX_BRACE_EXPANSION_TOTAL: usize = 100_000;
let mut count = 0;
self.expand_braces_capped(s, &mut count, MAX_BRACE_EXPANSION_TOTAL)
}

fn expand_braces_capped(&self, s: &str, count: &mut usize, max: usize) -> Vec<String> {
if *count >= max {
return vec![s.to_string()];
}

// Find the first brace that has a matching close brace
let mut depth = 0;
let mut brace_start = None;
Expand Down Expand Up @@ -8040,9 +8063,13 @@ impl Interpreter {
if let Some(range_result) = self.try_expand_range(&brace_content) {
let mut results = Vec::new();
for item in range_result {
if *count >= max {
break;
}
let expanded = format!("{}{}{}", prefix, item, suffix);
// Recursively expand any remaining braces
results.extend(self.expand_braces(&expanded));
let sub = self.expand_braces_capped(&expanded, count, max);
*count += sub.len();
results.extend(sub);
}
return results;
}
Expand All @@ -8057,16 +8084,24 @@ impl Interpreter {

let mut results = Vec::new();
for item in items {
if *count >= max {
break;
}
let expanded = format!("{}{}{}", prefix, item, suffix);
// Recursively expand any remaining braces
results.extend(self.expand_braces(&expanded));
let sub = self.expand_braces_capped(&expanded, count, max);
*count += sub.len();
results.extend(sub);
}

results
}

/// Try to expand a range like 1..5 or a..z
/// THREAT[TM-DOS-041]: Cap range size to prevent OOM from {1..999999999}
fn try_expand_range(&self, content: &str) -> Option<Vec<String>> {
/// Maximum number of elements in a brace range expansion
const MAX_BRACE_RANGE: u64 = 10_000;

// Check for .. separator
let parts: Vec<&str> = content.split("..").collect();
if parts.len() != 2 {
Expand All @@ -8078,6 +8113,10 @@ impl Interpreter {

// Try numeric range
if let (Ok(start_num), Ok(end_num)) = (start.parse::<i64>(), end.parse::<i64>()) {
let range_size = (end_num as i128 - start_num as i128).unsigned_abs() + 1;
if range_size > MAX_BRACE_RANGE as u128 {
return None; // Treat as literal — too large
}
let mut results = Vec::new();
if start_num <= end_num {
for i in start_num..=end_num {
Expand Down
22 changes: 20 additions & 2 deletions crates/bashkit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,8 @@ pub struct BashBuilder {
limits: ExecutionLimits,
username: Option<String>,
hostname: Option<String>,
/// Fixed epoch for virtualizing the `date` builtin (TM-INF-018)
fixed_epoch: Option<i64>,
custom_builtins: HashMap<String, Box<dyn Builtin>>,
/// Files to mount in the virtual filesystem
mounted_files: Vec<MountedFile>,
Expand Down Expand Up @@ -813,6 +815,15 @@ impl BashBuilder {
self
}

/// Set a fixed Unix epoch for the `date` builtin.
///
/// THREAT[TM-INF-018]: Prevents `date` from leaking real host time.
/// When set, `date` returns this fixed time instead of the real clock.
pub fn fixed_epoch(mut self, epoch: i64) -> Self {
self.fixed_epoch = Some(epoch);
self
}

/// Configure network access for curl/wget builtins.
///
/// Network access is disabled by default. Use this method to enable HTTP
Expand Down Expand Up @@ -1151,6 +1162,7 @@ impl BashBuilder {
self.env,
self.username,
self.hostname,
self.fixed_epoch,
self.cwd,
self.limits,
self.custom_builtins,
Expand All @@ -1170,6 +1182,7 @@ impl BashBuilder {
env: HashMap<String, String>,
username: Option<String>,
hostname: Option<String>,
fixed_epoch: Option<i64>,
cwd: Option<PathBuf>,
limits: ExecutionLimits,
custom_builtins: HashMap<String, Box<dyn Builtin>>,
Expand All @@ -1188,8 +1201,13 @@ impl BashBuilder {
"Bash instance configured"
);

let mut interpreter =
Interpreter::with_config(Arc::clone(&fs), username.clone(), hostname, custom_builtins);
let mut interpreter = Interpreter::with_config(
Arc::clone(&fs),
username.clone(),
hostname,
fixed_epoch,
custom_builtins,
);

// Set environment variables (also override shell variable defaults)
for (key, value) in &env {
Expand Down
Loading
Loading