diff --git a/crates/bashkit/docs/compatibility.md b/crates/bashkit/docs/compatibility.md index 93156ab09..e09bb37ca 100644 --- a/crates/bashkit/docs/compatibility.md +++ b/crates/bashkit/docs/compatibility.md @@ -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 | @@ -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 | diff --git a/crates/bashkit/src/builtins/archive.rs b/crates/bashkit/src/builtins/archive.rs index ad2c05d99..5f74d6c44 100644 --- a/crates/bashkit/src/builtins/archive.rs +++ b/crates/bashkit/src/builtins/archive.rs @@ -232,6 +232,8 @@ impl Builtin for Tar { let mut change_dir: Option = None; let mut strip_components = 0usize; let mut excludes: Vec = Vec::new(); + let mut absolute_names = false; + let mut files_from: Vec = Vec::new(); let mut files = budgeted_vec(&ctx)?; // GNU/bsdtar treat a bare first word as the historical option bundle. @@ -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") @@ -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)) { @@ -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(_) => { @@ -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 => { @@ -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 = 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 { @@ -401,7 +455,10 @@ impl Builtin for Tar { &ctx, &archive_name, &files, - verbose, + TarNames { + verbose, + absolute_names, + }, compression, change_dir.as_deref(), &excludes, @@ -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; @@ -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], @@ -473,7 +539,7 @@ async fn create_tar( file, &mut output_data, &mut verbose_output, - verbose, + names, excludes, ) .await?; @@ -485,7 +551,7 @@ async fn create_tar( file, &mut output_data, &mut verbose_output, - verbose, + names, ) .await?; } @@ -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 { "" @@ -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())?; @@ -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]); @@ -606,6 +676,15 @@ 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<'_>, @@ -613,10 +692,14 @@ fn add_directory_to_tar<'a>( name: &'a str, output: &'a mut BudgetedBytes, verbose_output: &'a mut BudgetedString, - verbose: bool, + names: TarNames, excludes: &'a [String], ) -> std::pin::Pin> + Send + 'a>> { Box::pin(async move { + let TarNames { + verbose, + absolute_names, + } = names; // Add directory entry if verbose { verbose_output.try_push_str(name)?; @@ -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]); @@ -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?; } } diff --git a/crates/bashkit/src/builtins/ls/list.rs b/crates/bashkit/src/builtins/ls/list.rs index 13693c7ae..52892848b 100644 --- a/crates/bashkit/src/builtins/ls/list.rs +++ b/crates/bashkit/src/builtins/ls/list.rs @@ -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 @@ -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", @@ -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. @@ -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::("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"), @@ -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 @@ -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("e_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 @@ -218,7 +247,7 @@ impl Builtin for Ls { let names: Vec = 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)); } @@ -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 @@ -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( + "e_name(&entry.name, opts.quoting), + &entry.metadata, + opts.human, + ); push_link_target( ctx, &vfs_join(path, &entry.name), @@ -326,7 +365,7 @@ async fn list_directory( // Collect entry names for potential column formatting let mut names: Vec = 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)); } diff --git a/crates/bashkit/src/builtins/ls/mod.rs b/crates/bashkit/src/builtins/ls/mod.rs index 527870ea7..c35a65455 100644 --- a/crates/bashkit/src/builtins/ls/mod.rs +++ b/crates/bashkit/src/builtins/ls/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod glob; mod list; +mod quoting; mod rmdir; pub(crate) use glob::{fnmatch, glob_match}; diff --git a/crates/bashkit/src/builtins/ls/quoting.rs b/crates/bashkit/src/builtins/ls/quoting.rs new file mode 100644 index 000000000..88f8041a4 --- /dev/null +++ b/crates/bashkit/src/builtins/ls/quoting.rs @@ -0,0 +1,166 @@ +//! `ls` name quoting styles (`-N`, `-b`, `-Q`, `--quoting-style=...`). +//! +//! Mirrors GNU quotearg for the styles agents hit in practice. Non-ASCII +//! text is treated as printable (a UTF-8 locale); only C0 controls and DEL +//! are escaped. `locale`/`clocale` styles are not implemented. + +use std::borrow::Cow; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) enum QuotingStyle { + #[default] + Literal, + Escape, + C, + Shell, + ShellAlways, + ShellEscape, + ShellEscapeAlways, +} + +impl QuotingStyle { + pub(super) fn from_name(name: &str) -> Option { + Some(match name { + "literal" => Self::Literal, + "escape" => Self::Escape, + "c" | "c-maybe" => Self::C, + "shell" => Self::Shell, + "shell-always" => Self::ShellAlways, + "shell-escape" => Self::ShellEscape, + "shell-escape-always" => Self::ShellEscapeAlways, + _ => return None, + }) + } +} + +/// Render `name` in `style`. +pub(super) fn quote_name(name: &str, style: QuotingStyle) -> Cow<'_, str> { + match style { + QuotingStyle::Literal => Cow::Borrowed(name), + QuotingStyle::Escape => Cow::Owned(c_escape(name, true, false)), + QuotingStyle::C => Cow::Owned(format!("\"{}\"", c_escape(name, false, true))), + QuotingStyle::Shell => shell_quote(name, false, false), + QuotingStyle::ShellAlways => shell_quote(name, true, false), + QuotingStyle::ShellEscape => shell_quote(name, false, true), + QuotingStyle::ShellEscapeAlways => shell_quote(name, true, true), + } +} + +fn is_control(c: char) -> bool { + (c as u32) < 0x20 || c == '\x7f' +} + +fn push_control_escape(out: &mut String, c: char) { + match c { + '\x07' => out.push_str("\\a"), + '\x08' => out.push_str("\\b"), + '\t' => out.push_str("\\t"), + '\n' => out.push_str("\\n"), + '\x0b' => out.push_str("\\v"), + '\x0c' => out.push_str("\\f"), + '\r' => out.push_str("\\r"), + _ => out.push_str(&format!("\\{:03o}", c as u32)), + } +} + +fn c_escape(name: &str, escape_space: bool, escape_dquote: bool) -> String { + let mut out = String::with_capacity(name.len()); + for c in name.chars() { + match c { + '\\' => out.push_str("\\\\"), + ' ' if escape_space => out.push_str("\\ "), + '"' if escape_dquote => out.push_str("\\\""), + c if is_control(c) => push_control_escape(&mut out, c), + c => out.push(c), + } + } + out +} + +/// Characters that force quoting in the `shell*` styles. +fn shell_special(c: char) -> bool { + matches!( + c, + ' ' | '!' + | '"' + | '$' + | '&' + | '\'' + | '(' + | ')' + | '*' + | ';' + | '<' + | '>' + | '?' + | '[' + | '\\' + | '^' + | '`' + | '{' + | '|' + | '}' + | '=' + ) || is_control(c) +} + +fn shell_quote(name: &str, always: bool, escape_controls: bool) -> Cow<'_, str> { + let needs = name.chars().any(shell_special) || name.starts_with(['#', '~']); + if !needs && !always { + return Cow::Borrowed(name); + } + let has_control = name.chars().any(is_control); + // GNU prefers "..." when `'` is the only awkward character. + if name.contains('\'') && !has_control && !name.contains(['$', '`', '"', '\\', '!']) { + return Cow::Owned(format!("\"{name}\"")); + } + let mut out = String::with_capacity(name.len() + 2); + out.push('\''); + for c in name.chars() { + if c == '\'' { + out.push_str("'\\''"); + } else if escape_controls && is_control(c) { + out.push_str("'$'"); + push_control_escape(&mut out, c); + // Close `$'...'` and reopen the plain quote. + out.push_str("''"); + } else { + out.push(c); + } + } + out.push('\''); + // A trailing control escape leaves an empty `''` reopen; GNU drops it. + if escape_controls && out.ends_with("''") && out.len() > 2 { + out.truncate(out.len() - 2); + } + Cow::Owned(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn styles_match_gnu() { + let cases: &[(&str, QuotingStyle, &str)] = &[ + ("a b", QuotingStyle::Escape, "a\\ b"), + ("c\td", QuotingStyle::Escape, "c\\td"), + ("z\u{1}", QuotingStyle::Escape, "z\\001"), + ("q\"x", QuotingStyle::C, "\"q\\\"x\""), + ("a b", QuotingStyle::C, "\"a b\""), + ("a#b", QuotingStyle::Shell, "a#b"), + ("#a", QuotingStyle::Shell, "'#a'"), + ("x=1", QuotingStyle::Shell, "'x=1'"), + ("it's", QuotingStyle::Shell, "\"it's\""), + ("a$'b", QuotingStyle::Shell, "'a$'\\''b'"), + ("a+b", QuotingStyle::ShellAlways, "'a+b'"), + ("c\td", QuotingStyle::ShellEscape, "'c'$'\\t''d'"), + ("z\u{1}", QuotingStyle::ShellEscape, "'z'$'\\001'"), + ("e'\tf", QuotingStyle::ShellEscape, "'e'\\'''$'\\t''f'"), + ("plain", QuotingStyle::ShellEscape, "plain"), + ]; + for (name, style, want) in cases { + assert_eq!(quote_name(name, *style), *want, "{name:?} {style:?}"); // debug-ok: test assertion message + } + } +} diff --git a/crates/bashkit/tests/spec_cases/bash/ls.test.sh b/crates/bashkit/tests/spec_cases/bash/ls.test.sh index 0c36cd3ad..fc08ac671 100644 --- a/crates/bashkit/tests/spec_cases/bash/ls.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/ls.test.sh @@ -117,26 +117,33 @@ sub/ ### end ### ls_quote_name_short_flag -### bash_diff: bashkit accepts -Q via the uu_ls argument surface but does -### bash_diff: not yet implement quoting; report not-yet-impl rather than -### bash_diff: silently fall through to default rendering. mkdir -p /tmp/lsqn echo x > /tmp/lsqn/a.txt ls -Q /tmp/lsqn 2>&1 echo "exit=$?" ### expect -ls: option(s) not yet implemented in bashkit: quote-name -exit=2 +"a.txt" +exit=0 ### end ### ls_quoting_style_long_flag -### bash_diff: --quoting-style= parsed by clap but not implemented. mkdir -p /tmp/lsqs echo x > /tmp/lsqs/a.txt ls --quoting-style=shell /tmp/lsqs 2>&1 echo "exit=$?" ### expect -ls: option(s) not yet implemented in bashkit: quoting-style +a.txt +exit=0 +### end + +### ls_quoting_style_locale_unsupported +### bash_diff: locale/clocale quoting (curly quotes) is not implemented. +mkdir -p /tmp/lsql +echo x > /tmp/lsql/a.txt +ls --quoting-style=locale /tmp/lsql 2>&1 +echo "exit=$?" +### expect +ls: quoting style 'locale' not yet implemented in bashkit exit=2 ### end @@ -179,3 +186,49 @@ ls --version | sed -E 's/^(ls) [0-9]+\.[0-9]+\.[0-9]+.*/\1 X.Y.Z/' ### expect ls X.Y.Z ### end + +### ls_escape_nongraphic +# The VFS rejects control characters in names, so only spaces and +# backslashes need escaping here; control-char escapes are unit-tested. +mkdir -p /tmp/lse && cd /tmp/lse && touch 'a b' plain 'g\h' +ls -b +echo -- +ls --escape | head -1 +### expect +a\ b +g\\h +plain +-- +a\ b +### end + +### ls_quoting_styles +mkdir -p /tmp/lqs && cd /tmp/lqs && touch 'a b' "it's" x=1 '#a' a#b plain 'q"x' +ls -Q | tr '\n' '|'; echo +ls --quoting-style=c | head -1 +ls --quoting-style=shell | tr '\n' '|'; echo +ls --quoting-style=shell-always | tr '\n' '|'; echo +ls -N | head -1 +ls -Q -N | head -1 +### expect +"#a"|"a b"|"a#b"|"it's"|"plain"|"q\"x"|"x=1"| +"#a" +'#a'|'a b'|a#b|"it's"|plain|'q"x'|'x=1'| +'#a'|'a b'|'a#b'|"it's"|'plain'|'q"x'|'x=1'| +#a +#a +### end + +### ls_quoted_recursive_header +# GNU escapes entries but not headers under -b; other styles quote both. +mkdir -p '/tmp/lqh/d d' && touch '/tmp/lqh/d d/f g' +cd /tmp/lqh && ls -bR +ls -QR | tr '\n' '|'; echo +### expect +.: +d\ d + +./d d: +f\ g +".":|"d d"||"./d d":|"f g"| +### end diff --git a/crates/bashkit/tests/spec_cases/bash/tar.test.sh b/crates/bashkit/tests/spec_cases/bash/tar.test.sh index 1088fff71..6c73207a6 100644 --- a/crates/bashkit/tests/spec_cases/bash/tar.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/tar.test.sh @@ -96,6 +96,8 @@ tar -cvf /tmp/tvs.tar tvs 2>/dev/null ### expect tvs/ tvs/f +### end + ### tar_strip_components # --strip-components drops leading path components on extract mkdir -p /tmp/sc/proj/src; echo a > /tmp/sc/proj/src/a.txt; echo r > /tmp/sc/proj/README @@ -143,3 +145,50 @@ tar --unknown-thing -tf /tmp/lm.tar 2>/dev/null || echo rejected lm/z rejected ### end + +### tar_absolute_names_keeps_slash +mkdir -p /tmp/tan && echo hi > /tmp/tan/f +tar -cPf /tmp/tan.tar /tmp/tan/f 2>/tmp/tan.err; echo "rc=$?" +wc -c < /tmp/tan.err +tar -tPf /tmp/tan.tar 2>/dev/null | grep -c '^/tmp/tan/f$' +### expect +rc=0 +0 +1 +### end + +### tar_absolute_names_long_option +mkdir -p /tmp/tal && echo hi > /tmp/tal/f +tar --absolute-names -cf /tmp/tal.tar /tmp/tal/f 2>/dev/null +tar -tPf /tmp/tal.tar 2>/dev/null | grep -c '^/tmp/tal/f$' +### expect +1 +### end + +### tar_files_from_list +mkdir -p /tmp/tff && cd /tmp/tff && echo a > a && echo b > b && echo c > c +printf 'a\n\nc\n' > list +tar -cf x.tar -T list && tar -tf x.tar +tar -cf y.tar --files-from=list && tar -tf y.tar | wc -l +printf 'b\n' | tar -cf z.tar -T - && tar -tf z.tar +### expect +a +c +2 +b +### end + +### tar_files_from_missing +cd /tmp && tar -cf /tmp/tfm.tar -T /tmp/no-such-list 2>&1; echo "rc=$?" +### expect +tar: /tmp/no-such-list: Cannot stat: No such file or directory +tar: Error is not recoverable: exiting now +rc=2 +### end + +### tar_double_dash_ends_options +mkdir -p /tmp/tdd && cd /tmp/tdd && echo a > ./-v +tar -cf x.tar -- -v && tar -tf x.tar +### expect +-v +### end diff --git a/knowledge/log.md b/knowledge/log.md index d3927f9e6..2375d2dc1 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,7 @@ ## 2026-10-07 +* **Fix**: `tar -P`/`--absolute-names` keeps the leading `/` (no notice), `-T FILE`/`--files-from` reads member names (blank lines skipped, `-` is stdin), and `--` ends options. `ls` implements the quoting styles: `-b`/`--escape`, `-N`, `-Q`, `--quoting-style=literal|escape|c|shell|shell-always|shell-escape|shell-escape-always` (GNU rules, including `"it's"` and headers left unescaped under `-b`); `locale`/`clocale` still report not implemented. Found by gap telemetry. * **Fix**: Arithmetic assigns to array elements. `((a[i]++))`, `((c[$k]+=3))`, `((a[2]=5))`, `((--a[0]))`, `let a[1]++` and `$((c[x]--))` read and write the element (indexed subscripts are arithmetic and accept negative indexes; associative keys are literal, quotes dropped as in `((c["x y"]++))`). Before, the update was lost or written to a scalar. Writes go through the budgeted `${a[i]:=v}` writer, so array memory limits still apply. Found by gap telemetry. * **Fix**: Gaps from the replayed agent calls: `set -n` / `set -o noexec` now stops running the rest of the script, as in bash (it was accepted and ignored); `find -ls` prints GNU's `ls -dils` layout; `tar -c` stores absolute paths without the leading `/` (with GNU's `Removing leading` notice) and `tar -cv` lists members on stdout unless the archive goes to stdout. Specs: `set-noexec.test.sh`, `find.test.sh`, `tar.test.sh`. * **Fix**: Quotes and patterns inside `${...}` operands. Unquoted `${x:-'d'}` keeps no quotes and `'$y'` stays literal; `"${x:-'d'}"` keeps them, as in bash; a `}` or `/` inside quotes no longer ends the operand. `${x/pattern/rep}` now expands `$var`, quotes and `\c` in the pattern (it used them verbatim) and matches globs leftmost-longest with `?`, `[...]` and any number of `*`; extglob patterns follow bash's empty-match scan. Globs compile to a size-capped regex, extglob search is budgeted (TM-DOS-127). Known gap: a quoted default with spaces (`${x:-'a b'}`) is still word-split. Spec: `param-operand-quotes.test.sh`.