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 crates/bashkit/docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ for sandbox security reasons. See the compliance spec for details.
| `wget` | `-q`, `-O`, `--spider`, `--header`, `-U`, `--post-data`, `-t` | Download files (requires http_client feature) |
| `time` | `[-p] [-f FORMAT] [-o FILE] [-a] [-v] [--] PIPELINE` | Reserved-word timing; elapsed/status/Bashkit counters are truthful, host CPU/RSS fields say `unavailable` |
| `timeout` | `DURATION COMMAND` | Run with time limit (stub) |
| `ls` | `-l`, `-a`, `-h`, `-1`, `-R`, `-t`, `-F`, `-C`, `-d` | List directory contents |
| `ls` | `-l`, `-a`, `-h`, `-1`, `-R`, `-t`, `-F`, `-C`, `-d`, `-b`, `-N`, `-Q`, `--quoting-style` (not `locale`/`clocale`) | List directory contents |
| `find` | GNU expression grammar (`( )`, `!`, `-a`, `-o`, `,`), `-H`/`-L`/`-P`, `-name`/`-iname`, `-path`/`-ipath`, `-regex`/`-iregex`/`-regextype`, `-type`/`-xtype`, `-size`, `-empty`, `-mtime`/`-mmin`/`-newer`/`-newerXY`, `-perm`, `-user`/`-group`/`-uid`/`-gid`, `-maxdepth`/`-mindepth`/`-depth`, `-print`/`-print0`/`-printf`, `-exec`/`-execdir` (`;` and `+`), `-delete`, `-prune`, `-quit` | Search for files (gaps: L-FIND-001) |
| `rmdir` | `-p` | Remove empty directories |
| `xargs` | `-I`, `-n`, `-d` | Build commands from stdin |
Expand All @@ -113,7 +113,7 @@ for sandbox security reasons. See the compliance spec for details.
| `file` | (none) | Detect file type via magic bytes |
| `less` | (none) | View file (behaves like cat in virtual mode) |
| `stat` | `-c FORMAT` | Display file metadata |
| `tar` | `-c`, `-x`, `-t`, `-v`, `-f`, `-z`/`--gzip`, `-j`/`--bzip2` | Archive operations; auto-detects gzip/bzip2 while reading |
| `tar` | `-c`, `-x`, `-t`, `-v`, `-f`, `-z`/`--gzip`, `-j`/`--bzip2`, `-C`, `-O`, `-P`/`--absolute-names`, `-T`/`--files-from`, `--strip-components`, `--exclude`, `--` | Archive operations; auto-detects gzip/bzip2 while reading |
| `gzip` | `-d`, `-k`, `-f` | Compress files |
| `gunzip` | `-k`, `-f` | Decompress files |
| `bzip2` | `-c`, `-d`, `-z`, `-k`, `-f` | Compress or decompress bzip2 streams/files |
Expand Down
118 changes: 97 additions & 21 deletions crates/bashkit/src/builtins/archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,8 @@ impl Builtin for Tar {
let mut change_dir: Option<String> = None;
let mut strip_components = 0usize;
let mut excludes: Vec<String> = Vec::new();
let mut absolute_names = false;
let mut files_from: Vec<String> = Vec::new();
let mut files = budgeted_vec(&ctx)?;

// GNU/bsdtar treat a bare first word as the historical option bundle.
Expand All @@ -254,6 +256,14 @@ impl Builtin for Tar {
// Parse arguments
let mut p = super::arg_parser::ArgParser::new(args);
while !p.is_done() {
if p.current() == Some("--") {
// End of options: everything after is a member name.
p.advance();
while let Some(arg) = p.positional() {
files.try_push(arg)?;
}
break;
}
if let Some(long) = p.current().and_then(|a| a.strip_prefix("--"))
&& !long.is_empty()
&& !matches!(long, "bzip2" | "gzip")
Expand All @@ -266,7 +276,7 @@ impl Builtin for Tar {
p.advance();
let takes_value = matches!(
name.as_str(),
"strip-components" | "exclude" | "file" | "directory"
"strip-components" | "exclude" | "file" | "directory" | "files-from"
);
let value = if takes_value {
match inline.or_else(|| p.positional().map(str::to_string)) {
Expand All @@ -290,6 +300,8 @@ impl Builtin for Tar {
"file" => archive_file = Some(value),
"directory" => change_dir = Some(value),
"exclude" => excludes.push(value),
"files-from" => files_from.push(value),
"absolute-names" => absolute_names = true,
"strip-components" => match value.parse() {
Ok(n) => strip_components = n,
Err(_) => {
Expand Down Expand Up @@ -342,6 +354,16 @@ impl Builtin for Tar {
'O' => to_stdout = true,
// Permissions are not modelled by the VFS.
'p' => {}
'P' => absolute_names = true,
'T' => match p.positional() {
Some(val) => files_from.push(val.to_string()),
None => {
return Ok(ExecResult::err(
"tar: option requires an argument -- 'T'\n".to_string(),
2,
));
}
},
'f' => match p.positional() {
Some(val) => archive_file = Some(val.to_string()),
None => {
Expand Down Expand Up @@ -373,6 +395,38 @@ impl Builtin for Tar {
}
}

// `-T FILE` / `--files-from=FILE`: one member name per line, blank
// lines skipped, `-` reads stdin.
let mut listed_names: Vec<String> = Vec::new();
for list in &files_from {
let content = if list == "-" {
String::from_utf8_lossy(ctx.stdin_bytes().unwrap_or_default()).into_owned()
} else {
let path = resolve_path(ctx.cwd, list);
match ctx.fs.read_file(&path).await {
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
Err(_) => {
return Ok(ExecResult::err(
format!(
"tar: {list}: Cannot stat: No such file or directory\ntar: Error is not recoverable: exiting now\n"
),
2,
));
}
}
};
ctx.consume_budget_input(content.len())?;
listed_names.extend(
content
.lines()
.filter(|l| !l.is_empty())
.map(str::to_string),
);
}
for name in &listed_names {
files.try_push(name.as_str())?;
}

// Check for exactly one of -c, -x, -t
let mode_count = [create, extract, list].iter().filter(|&&x| x).count();
if mode_count == 0 {
Expand Down Expand Up @@ -401,7 +455,10 @@ impl Builtin for Tar {
&ctx,
&archive_name,
&files,
verbose,
TarNames {
verbose,
absolute_names,
},
compression,
change_dir.as_deref(),
&excludes,
Expand All @@ -428,6 +485,15 @@ impl Builtin for Tar {
}
}

/// Per-archive member naming flags shared by the tar writers.
#[derive(Clone, Copy)]
struct TarNames {
/// `-v`: list each member as it is added.
verbose: bool,
/// `-P`: keep the leading `/` of absolute member names.
absolute_names: bool,
}

/// Simple tar header (512 bytes)
const TAR_BLOCK_SIZE: usize = 512;

Expand All @@ -436,7 +502,7 @@ async fn create_tar(
ctx: &Context<'_>,
archive_name: &str,
files: &[&str],
verbose: bool,
names: TarNames,
compression: ArchiveCompression,
change_dir: Option<&str>,
excludes: &[String],
Expand Down Expand Up @@ -473,7 +539,7 @@ async fn create_tar(
file,
&mut output_data,
&mut verbose_output,
verbose,
names,
excludes,
)
.await?;
Expand All @@ -485,7 +551,7 @@ async fn create_tar(
file,
&mut output_data,
&mut verbose_output,
verbose,
names,
)
.await?;
}
Expand All @@ -503,7 +569,7 @@ async fn create_tar(
};
let (final_data, _final_lease) = final_data.into_parts();
let (verbose_output, _verbose_lease) = verbose_output.into_parts();
let notice = if files.iter().any(|f| f.starts_with('/')) {
let notice = if !names.absolute_names && files.iter().any(|f| f.starts_with('/')) {
"tar: Removing leading `/' from member names\n"
} else {
""
Expand Down Expand Up @@ -540,8 +606,12 @@ async fn add_file_to_tar(
name: &str,
output: &mut BudgetedBytes,
verbose_output: &mut BudgetedString,
verbose: bool,
names: TarNames,
) -> Result<()> {
let TarNames {
verbose,
absolute_names,
} = names;
let metadata = ctx.fs.stat(path).await?;
let content = ctx.fs.read_file(path).await?;
ctx.consume_budget_input(content.len())?;
Expand All @@ -555,8 +625,8 @@ async fn add_file_to_tar(
// Create tar header
let mut header = [0u8; TAR_BLOCK_SIZE];

// Name (100 bytes); GNU tar drops the leading `/` of absolute names.
let name_bytes = name.trim_start_matches('/').as_bytes();
// Name (100 bytes); GNU tar drops the leading `/` unless `-P`.
let name_bytes = tar_member_name(name, absolute_names).as_bytes();
let name_len = name_bytes.len().min(100);
header[..name_len].copy_from_slice(&name_bytes[..name_len]);

Expand Down Expand Up @@ -606,17 +676,30 @@ async fn add_file_to_tar(
Ok(())
}

/// Header name for a member: leading `/` dropped unless `-P`/`--absolute-names`.
fn tar_member_name(name: &str, absolute_names: bool) -> &str {
if absolute_names {
name
} else {
name.trim_start_matches('/')
}
}

/// Add a directory to tar archive recursively
fn add_directory_to_tar<'a>(
ctx: &'a Context<'_>,
path: &'a Path,
name: &'a str,
output: &'a mut BudgetedBytes,
verbose_output: &'a mut BudgetedString,
verbose: bool,
names: TarNames,
excludes: &'a [String],
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
Box::pin(async move {
let TarNames {
verbose,
absolute_names,
} = names;
// Add directory entry
if verbose {
verbose_output.try_push_str(name)?;
Expand All @@ -629,7 +712,7 @@ fn add_directory_to_tar<'a>(
let mut header = [0u8; TAR_BLOCK_SIZE];

// Name with trailing slash
let dir_name = format!("{}/", name.trim_start_matches('/'));
let dir_name = format!("{}/", tar_member_name(name, absolute_names));
let name_bytes = dir_name.as_bytes();
let name_len = name_bytes.len().min(100);
header[..name_len].copy_from_slice(&name_bytes[..name_len]);
Expand Down Expand Up @@ -684,20 +767,13 @@ fn add_directory_to_tar<'a>(
&child_name,
output,
verbose_output,
verbose,
names,
excludes,
)
.await?;
} else {
add_file_to_tar(
ctx,
&child_path,
&child_name,
output,
verbose_output,
verbose,
)
.await?;
add_file_to_tar(ctx, &child_path, &child_name, output, verbose_output, names)
.await?;
}
}

Expand Down
49 changes: 44 additions & 5 deletions crates/bashkit/src/builtins/ls/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ use crate::fs::FileType;
use crate::fs::vfs_join;
use crate::interpreter::ExecResult;

use super::quoting::{QuotingStyle, quote_name};

/// Argument IDs from the generated `ls_command()` that bashkit currently
/// implements. Anything else clap accepts is reported as "not yet
/// implemented" so scripts get a deterministic error instead of silently
Expand All @@ -31,6 +33,10 @@ const LS_SUPPORTED_IDS: &[&str] = &[
"classify", // -F / --classify
"C", // -C
"directory", // -d / --directory
"quoting-style", // --quoting-style (not locale/clocale)
"literal", // -N
"escape", // -b
"quote-name", // -Q
// Non-flag positional + always-supported infrastructure.
"paths",
"help",
Expand All @@ -47,6 +53,7 @@ pub(super) struct LsOptions {
pub(super) classify: bool,
pub(super) columns: bool,
pub(super) directory: bool,
pub(super) quoting: QuotingStyle,
}

/// The ls builtin - list directory contents.
Expand Down Expand Up @@ -129,6 +136,26 @@ impl Builtin for Ls {
.map(|v| v != "never")
.unwrap_or(true);

// The four quoting flags override each other in clap, so at most
// one is present.
let quoting = if matches.get_flag("escape") {
QuotingStyle::Escape
} else if matches.get_flag("quote-name") {
QuotingStyle::C
} else if let Some(style) = matches.get_one::<String>("quoting-style") {
match QuotingStyle::from_name(style) {
Some(q) => q,
None => {
return Ok(ExecResult::err(
format!("ls: quoting style '{style}' not yet implemented in bashkit\n"),
2,
));
}
}
} else {
QuotingStyle::Literal
};

let opts = LsOptions {
long: matches.get_flag("long"),
all: matches.get_flag("all"),
Expand All @@ -139,6 +166,7 @@ impl Builtin for Ls {
classify,
columns: matches.get_flag("C"),
directory: matches.get_flag("directory"),
quoting,
};

// PATHS holds OsString values; convert to owned strings for the
Expand Down Expand Up @@ -203,7 +231,8 @@ impl Builtin for Ls {
// Output file arguments first (preserving path as given by user)
if opts.long {
for (path_str, path, metadata) in &file_args {
let mut entry = format_long_entry(path_str, metadata, opts.human);
let mut entry =
format_long_entry(&quote_name(path_str, opts.quoting), metadata, opts.human);
push_link_target(&ctx, path, metadata, &mut entry).await;
if opts.classify {
// Insert suffix before the trailing newline
Expand All @@ -218,7 +247,7 @@ impl Builtin for Ls {
let names: Vec<String> = file_args
.iter()
.map(|(path_str, _, metadata)| {
let mut name = (*path_str).to_string();
let mut name = quote_name(path_str, opts.quoting).into_owned();
if opts.classify {
name.push_str(classify_suffix(metadata));
}
Expand Down Expand Up @@ -270,7 +299,13 @@ async fn list_directory(
}

if show_header {
output.push_str(&format!("{}:\n", display_path));
// GNU quotes headers in every style except `escape`.
let header = if opts.quoting == QuotingStyle::Escape {
std::borrow::Cow::Borrowed(display_path)
} else {
quote_name(display_path, opts.quoting)
};
output.push_str(&format!("{}:\n", header));
}

let entries = ctx
Expand Down Expand Up @@ -300,7 +335,11 @@ async fn list_directory(

if opts.long {
for entry in &filtered {
let mut line = format_long_entry(&entry.name, &entry.metadata, opts.human);
let mut line = format_long_entry(
&quote_name(&entry.name, opts.quoting),
&entry.metadata,
opts.human,
);
push_link_target(
ctx,
&vfs_join(path, &entry.name),
Expand All @@ -326,7 +365,7 @@ async fn list_directory(
// Collect entry names for potential column formatting
let mut names: Vec<String> = Vec::new();
for entry in &filtered {
let mut name = entry.name.clone();
let mut name = quote_name(&entry.name, opts.quoting).into_owned();
if opts.classify {
name.push_str(classify_suffix(&entry.metadata));
}
Expand Down
1 change: 1 addition & 0 deletions crates/bashkit/src/builtins/ls/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

pub(crate) mod glob;
mod list;
mod quoting;
mod rmdir;

pub(crate) use glob::{fnmatch, glob_match};
Expand Down
Loading
Loading