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: 3 additions & 1 deletion crates/bashkit-python/tests/_security_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,9 @@ def test_tm_inf_002_default_env_vars_are_sandboxed():
"""TM-INF-002: builtin shell vars should resolve to virtual sandbox values."""
r = Bash().execute_sync('echo "HOME=$HOME PATH=$PATH USER=$USER HOSTNAME=$HOSTNAME"')
assert r.exit_code == 0
assert r.stdout.strip() == "HOME=/home/sandbox PATH= USER=sandbox HOSTNAME=bashkit-sandbox"
assert (
r.stdout.strip() == "HOME=/home/sandbox PATH=/usr/local/bin:/usr/bin:/bin USER=sandbox HOSTNAME=bashkit-sandbox"
)


def test_tm_esc_002_process_substitution_stays_in_sandbox():
Expand Down
22 changes: 22 additions & 0 deletions crates/bashkit/src/builtins/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ use crate::interpreter::ExecResult;
/// the real clock so callers can blind absolute wall-clock time without
/// breaking elapsed-time logic. The two modes are mutually exclusive
/// — `fixed_epoch` wins if both are set.
#[derive(Clone, Copy, Default)]
pub struct Date {
/// Fixed UTC epoch for virtualized time. None = use real system clock.
fixed_epoch: Option<DateTime<Utc>>,
Expand Down Expand Up @@ -172,6 +173,27 @@ impl Date {
}
}

/// Format epoch `seconds` (or now, for `None`) with strftime `format`
/// in the sandbox `TZ`. Shared with printf's `%(fmt)T` so both builtins
/// see one virtual clock and one timezone policy.
pub(super) fn strftime(
&self,
tz: Option<&String>,
seconds: Option<i64>,
format: &str,
) -> std::result::Result<String, String> {
let timezone =
SandboxTimezone::from_env(tz).map_err(|e| e.replacen("date:", "printf:", 1))?;
let dt = match seconds {
Some(s) => DateTime::from_timestamp(s, 0)
.ok_or_else(|| format!("printf: time out of range: {s}"))?,
None => self.now(),
};
let format = translate_gnu_format(format);
validate_format(&format).map_err(|e| format!("printf: {e}"))?;
Ok(timezone.format(&dt, &format))
}

fn now(&self) -> DateTime<Utc> {
if let Some(t) = self.fixed_epoch {
return t;
Expand Down
19 changes: 16 additions & 3 deletions crates/bashkit/src/builtins/git/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,16 @@ impl GitClient {
/// The path must be within the virtual filesystem. Path traversal
/// attacks are blocked by the VFS layer.
pub async fn init(&self, fs: &Arc<dyn FileSystem>, repo_path: &Path) -> Result<String> {
self.init_with_branch(fs, repo_path, "master").await
}

/// `git init -b <branch>`: HEAD points at `refs/heads/<branch>`.
pub async fn init_with_branch(
&self,
fs: &Arc<dyn FileSystem>,
repo_path: &Path,
initial_branch: &str,
) -> Result<String> {
let git_dir = vfs_join(repo_path, ".git");

// Check if already initialized
Expand All @@ -110,9 +120,12 @@ impl GitClient {
fs.mkdir(&vfs_join(&git_dir, "refs/heads"), true).await?;
fs.mkdir(&vfs_join(&git_dir, "refs/tags"), true).await?;

// Create HEAD pointing to master
fs.write_file(&vfs_join(&git_dir, "HEAD"), b"ref: refs/heads/master\n")
.await?;
// Create HEAD pointing to the initial branch
fs.write_file(
&vfs_join(&git_dir, "HEAD"),
format!("ref: refs/heads/{initial_branch}\n").as_bytes(),
)
.await?;

// Create config with author info
let config_content = format!(
Expand Down
81 changes: 75 additions & 6 deletions crates/bashkit/src/builtins/git/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,25 +130,94 @@ async fn execute_git(ctx: Context<'_>, git_client: &super::GitClient) -> Result<
}
}

/// `git check-ref-format --branch` rules, enough to keep HEAD well formed.
#[cfg(feature = "git")]
fn valid_branch_name(name: &str) -> bool {
!name.is_empty()
&& !name.starts_with(['-', '/', '.'])
&& !name.ends_with(['/', '.'])
&& !name.ends_with(".lock")
&& !name.contains("..")
&& !name.contains("@{")
&& !name.contains("//")
&& name != "@"
&& !name
.chars()
.any(|c| c.is_control() || c.is_whitespace() || "~^:?*[\\".contains(c))
}

#[cfg(feature = "git")]
async fn git_init(
ctx: Context<'_>,
git_client: &super::GitClient,
args: &[String],
) -> Result<ExecResult> {
// Parse path argument (default to cwd)
let path = if args.is_empty() {
ctx.cwd.clone()
} else {
resolve_path(ctx.cwd, &args[0])
let mut quiet = false;
let mut branch = "master".to_string();
let mut dir: Option<&str> = None;
let mut i = 0;
while i < args.len() {
let arg = args[i].as_str();
match arg {
"-q" | "--quiet" => quiet = true,
"-b" | "--initial-branch" => {
i += 1;
match args.get(i) {
Some(name) => branch = name.clone(),
None => {
return Ok(ExecResult::err(
format!("error: option `{arg}' requires a value\n"),
129,
));
}
}
}
_ if arg.starts_with("--initial-branch=") => {
branch = arg["--initial-branch=".len()..].to_string();
}
"--" => {
dir = args.get(i + 1).map(String::as_str);
break;
}
_ if arg.starts_with('-') => {
return Ok(ExecResult::err(
format!("error: unknown option `{}'\n", arg.trim_start_matches('-')),
129,
));
}
_ => {
if dir.is_some() {
return Ok(ExecResult::err(
"usage: git init [-q | --quiet] [-b <branch-name>] [<directory>]\n"
.to_string(),
129,
));
}
dir = Some(arg);
}
}
i += 1;
}
if !valid_branch_name(&branch) {
return Ok(ExecResult::err(
format!("fatal: invalid initial branch name: '{branch}'\n"),
128,
));
}

// Path argument defaults to cwd
let path = match dir {
Some(d) => resolve_path(ctx.cwd, d),
None => ctx.cwd.clone(),
};

// Create directory if it doesn't exist
if !ctx.fs.exists(&path).await? {
ctx.fs.mkdir(&path, true).await?;
}

match git_client.init(&ctx.fs, &path).await {
match git_client.init_with_branch(&ctx.fs, &path, &branch).await {
Ok(_) if quiet => Ok(ExecResult::ok(String::new())),
Ok(output) => Ok(ExecResult::ok(output)),
Err(e) => git_err(e),
}
Expand Down
171 changes: 169 additions & 2 deletions crates/bashkit/src/builtins/printf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,24 @@ use super::generated::format::{
FormatArgument, FormatArguments, FormatError, FormatItem, parse_spec_and_escape,
};
use super::limits::PRINTF_MAX_DIAG_CHARS as MAX_PRINTF_DIAG_CHARS;
use super::{Builtin, Context, MAX_FORMAT_WIDTH};
use super::{Builtin, Context, Date, MAX_FORMAT_WIDTH};
use crate::error::Result;
use crate::interpreter::{ExecResult, is_internal_variable};

/// printf builtin - formatted string output
pub struct Printf;
///
/// Holds the sandbox clock so `%(fmt)T` reads the same virtual time as `date`.
#[derive(Default)]
pub struct Printf {
clock: Date,
}

impl Printf {
/// printf whose `%(fmt)T` uses `clock` (fixed epoch / offset aware).
pub fn with_clock(clock: Date) -> Self {
Self { clock }
}
}

#[async_trait]
impl Builtin for Printf {
Expand Down Expand Up @@ -51,6 +63,12 @@ impl Builtin for Printf {
};

let args: Vec<String> = args_iter.cloned().collect();
let (format, args) = match expand_time_directives(&format, args, |seconds, fmt| {
self.clock.strftime(ctx.env.get("TZ"), seconds, fmt)
}) {
Ok(v) => v,
Err(err) => return Ok(ExecResult::err(format!("{err}\n"), 1)),
};
let output = match render_printf(&format, &args) {
Ok(output) => output,
Err(err) => return Ok(ExecResult::err(err, 1)),
Expand Down Expand Up @@ -96,6 +114,118 @@ fn render_printf(format: &str, args: &[String]) -> std::result::Result<String, S
Ok(bytes_to_stdout_string(out))
}

/// One argument-consuming slot of a format pass.
enum Slot {
Plain,
/// `%(fmt)T` with its strftime format.
Time(String),
}

/// Rewrite bash's `%(fmt)T` into `%s` and pre-format the matching arguments.
///
/// Decision: the generated uutils formatter has no `%(...)T`, and it is
/// generated code we don't edit. So each `%[flags][width][.prec](fmt)T`
/// becomes `%[flags][width][.prec]s`, and every argument that lands on such a
/// directive (arg i -> slot i % slots, as the format repeats) is replaced by
/// the formatted time. Argument `""` or `-1` means now. `-2` (bash: shell
/// start time) also means now; the shell start instant isn't tracked.
/// Missing arguments for a time slot also mean now.
fn expand_time_directives(
format: &str,
mut args: Vec<String>,
mut strftime: impl FnMut(Option<i64>, &str) -> std::result::Result<String, String>,
) -> std::result::Result<(String, Vec<String>), String> {
if !format.contains(")T") {
return Ok((format.to_string(), args));
}
let bytes = format.as_bytes();
let mut out = String::with_capacity(format.len());
let mut slots = Vec::new();
let mut i = 0;
let mut copied = 0;
while i < bytes.len() {
match bytes[i] {
b'\\' => i += 2,
b'%' if bytes.get(i + 1) == Some(&b'%') => i += 2,
b'%' => {
i += 1;
while i < bytes.len() && b"-+ #0'".contains(&bytes[i]) {
i += 1;
}
for part in 0..2 {
if part == 1 {
if bytes.get(i) != Some(&b'.') {
break;
}
i += 1;
}
if bytes.get(i) == Some(&b'*') {
slots.push(Slot::Plain);
i += 1;
} else {
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
}
}
if bytes.get(i) == Some(&b'(')
&& let Some(close) = format[i..].find(")T")
{
let fmt = &format[i + 1..i + close];
out.push_str(&format[copied..i]);
out.push('s');
slots.push(Slot::Time(if fmt.is_empty() {
"%X".to_string()
} else {
fmt.to_string()
}));
i += close + 2;
copied = i;
} else if i < bytes.len() {
slots.push(Slot::Plain);
i += 1;
}
}
_ => i += 1,
}
}
out.push_str(&format[copied..]);
if !slots.iter().any(|s| matches!(s, Slot::Time(_))) {
return Ok((format.to_string(), args));
}

// Pad the last pass up to its final time slot so a missing time
// argument still prints "now" (bash treats it as -1).
let n = slots.len();
let last_time = slots
.iter()
.rposition(|s| matches!(s, Slot::Time(_)))
.unwrap_or(0);
let pass_start = if args.is_empty() {
0
} else {
(args.len() - 1) / n * n
};
let filled = args.len() - pass_start;
if args.is_empty() || filled < n {
let want = pass_start + last_time + 1;
while args.len() < want {
args.push(String::new());
}
}

for (idx, arg) in args.iter_mut().enumerate() {
if let Slot::Time(fmt) = &slots[idx % n] {
let seconds = match arg.trim() {
"" | "-1" | "-2" => None,
s => Some(parse_leading_i64(s)),
};
*arg = strftime(seconds, fmt)?;
}
}
Ok((out, args))
}

fn bytes_to_stdout_string(bytes: Vec<u8>) -> String {
String::from_utf8_lossy(&bytes).into_owned()
}
Expand Down Expand Up @@ -518,6 +648,43 @@ mod tests {
use super::*;
use crate::interpreter::ExecResult;

fn fake_time(seconds: Option<i64>, fmt: &str) -> std::result::Result<String, String> {
Ok(format!(
"<{fmt}@{}>",
seconds.map_or("now".into(), |s| s.to_string())
))
}

#[test]
fn time_directive_rewritten_to_string_slot() {
let (f, a) =
expand_time_directives("%-5(%H)T|%s\\n", vec!["7".into(), "x".into()], fake_time)
.unwrap();
assert_eq!(f, "%-5s|%s\\n");
assert_eq!(a, vec!["<%H@7>".to_string(), "x".to_string()]);
}

#[test]
fn time_directive_maps_args_across_passes_and_pads_missing() {
let (_, a) = expand_time_directives(
"%s %(%F)T",
vec!["a".into(), "1".into(), "b".into()],
fake_time,
)
.unwrap();
assert_eq!(a, vec!["a", "<%F@1>", "b", "<%F@now>"]);
let (_, a) = expand_time_directives("%()T", vec![], fake_time).unwrap();
assert_eq!(a, vec!["<%X@now>"]);
}

#[test]
fn time_directive_skips_literal_percent_and_plain_formats() {
let (f, a) = expand_time_directives("%% %s", vec!["x".into()], fake_time).unwrap();
assert_eq!((f.as_str(), a), ("%% %s", vec!["x".to_string()]));
let err = expand_time_directives("%(%F)T", vec![], |_, _| Err("bad".into()));
assert_eq!(err.unwrap_err(), "bad");
}

#[test]
fn generated_formatter_repeats_format_until_args_exhausted() {
assert_eq!(
Expand Down
2 changes: 2 additions & 0 deletions crates/bashkit/src/fs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ mod readonly;
#[cfg(feature = "realfs")]
mod realfs;
mod search;
mod std_streams;
mod traits;

pub use backend::FsBackend;
Expand All @@ -427,6 +428,7 @@ pub use search::{
SearchCapabilities, SearchCapable, SearchMatch, SearchProvider, SearchQuery, SearchResults,
};
#[allow(unused_imports)]
pub(crate) use std_streams::{StdStreamsFs, args_name_std_stream};
pub use traits::{DirEntry, FileSystem, FileSystemExt, FileType, Metadata, fs_errors};

use crate::error::Result;
Expand Down
Loading
Loading