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
13 changes: 12 additions & 1 deletion src/tools/bash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use anyhow::{Context, bail};

use crate::bounded_process::{self, BoundedProcessOutcomeKind};

mod path_tokens;
pub(crate) mod path_tokens;
mod read_guard;

const DEFAULT_TIMEOUT: Duration = Duration::from_secs(180);
Expand Down Expand Up @@ -815,6 +815,17 @@ pub fn path_confinement_rejection(
}
}
}
// Issue #613 design 2: a `/`-less static word can be an outward symlink that
// no directory separator exposes. It is proven when joined onto the
// workspace root and each working-directory candidate, but only when the
// joined path actually is a symlink (the read guard holds that body). An
// undetermined working directory is left to the relative-candidate branch
// below, which already refuses the words a `cd` could redirect.
if let Some(word) =
read_guard::relative_word_rejection(&read.relative_words, &root, &inspection.bases)
{
return Some(path_reference_rejection(&word, &root, None));
}
if inspection.has_working_directory() {
if inspection.undecidable && !inspection.relative_words.is_empty() {
return Some(path_reference_rejection(
Expand Down
54 changes: 48 additions & 6 deletions src/tools/bash/path_tokens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@
//! the existing write-side proof, and a word whose value cannot be determined is
//! refused. This module never returns a *result*; it only classifies.

/// Whether a carriage return separates words or is an ordinary word character
/// (Issue #613). Bash reads a space, a tab, and a newline as word separators and
/// keeps a carriage return inside the word, so the same text has two readings:
/// the historical one that treats `\r` as a separator and might miss an outward
/// symlink whose name holds a `\r`, and the shell-faithful one that keeps it.
/// The guard judges a command with both readings and refuses or recognizes when
/// either does.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CrMode {
/// A carriage return separates words (the historical reading).
Separator,
/// A carriage return is an ordinary word character, as Bash reads it.
WordChar,
}

/// One shell word with the metadata the read-path judge needs.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(super) struct Word {
Expand Down Expand Up @@ -49,7 +64,7 @@ impl Word {
/// Split a command into segments of words, tracking shell expansions without
/// running them. Returns `None` when the quoting cannot be read (an unterminated
/// quote), so the caller fails closed.
pub(super) fn read_words(command: &str) -> Option<Vec<Vec<Word>>> {
pub(super) fn read_words(command: &str, cr_mode: CrMode) -> Option<Vec<Vec<Word>>> {
let chars: Vec<char> = command.chars().collect();
let mut segments: Vec<Vec<Word>> = Vec::new();
let mut words: Vec<Word> = Vec::new();
Expand Down Expand Up @@ -153,7 +168,9 @@ pub(super) fn read_words(command: &str) -> Option<Vec<Vec<Word>>> {
has_word = true;
index += 1;
}
';' | '|' | '&' | '<' | '>' | '(' | ')' | '\n' | '\r' => {
';' | '|' | '&' | '<' | '>' | '(' | ')' | '\n' | '\r'
if cr_mode == CrMode::Separator || ch != '\r' =>
{
flush(&mut words, &mut word, &mut has_word);
if matches!(ch, ';' | '|' | '&') && chars.get(index + 1) == Some(&ch) {
index += 1;
Expand All @@ -162,10 +179,11 @@ pub(super) fn read_words(command: &str) -> Option<Vec<Vec<Word>>> {
index += 1;
}
// Only the shell's default word separators end a word: a space and a
// tab (the newline and carriage return are handled above). Every
// other Unicode whitespace — an ideographic space, a no-break space —
// is an ordinary character of the word, which is what the shell
// reads (Issue #603 design 2).
// tab (the newline is handled above; a carriage return is a separator
// only in [`CrMode::Separator`] and reaches the `other` arm in
// [`CrMode::WordChar`]). Every other Unicode whitespace — an
// ideographic space, a no-break space — is an ordinary character of
// the word, which is what the shell reads (Issue #603 design 2).
' ' | '\t' => {
flush(&mut words, &mut word, &mut has_word);
index += 1;
Expand Down Expand Up @@ -396,6 +414,11 @@ pub(super) fn contains_glob_or_brace(value: &str) -> bool {
mod tests {
use super::*;

/// The historical reading, where a carriage return separates words.
fn read_words(command: &str) -> Option<Vec<Vec<Word>>> {
super::read_words(command, CrMode::Separator)
}

fn words(command: &str) -> Vec<String> {
read_words(command)
.unwrap_or_else(|| panic!("expected readable words: {command}"))
Expand Down Expand Up @@ -522,6 +545,25 @@ mod tests {
assert_eq!(words("cat a\nb"), ["cat", "a", "b"]);
}

/// Issue #613: Bash keeps a carriage return inside the word. The
/// `WordChar` reading reproduces that, while `Separator` keeps the historical
/// split. A newline still ends the word in both readings.
#[test]
fn carriage_return_word_reading_table() {
let word_char = |command: &str| {
super::read_words(command, CrMode::WordChar)
.unwrap_or_else(|| panic!("expected readable words: {command}"))
.into_iter()
.flatten()
.map(|word| word.text)
.collect::<Vec<_>>()
};
assert_eq!(word_char("cat lfcr\r"), ["cat", "lfcr\r"]);
assert_eq!(word_char("cat a\rb"), ["cat", "a\rb"]);
assert_eq!(word_char("cat a\r\nb"), ["cat", "a\r", "b"]);
assert_eq!(words("cat a\rb"), ["cat", "a", "b"]);
}

/// The whitespace-allowing form accepts a quoted or escaped single word and a
/// `=` right-hand side, but still refuses a multiword program, an option, and
/// an assignment (Issue #603 designs 1 and 3).
Expand Down
180 changes: 176 additions & 4 deletions src/tools/bash/read_guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
//! or runs a substitution to resolve a word, and it is never used to prove a
//! later variable reference safe.

use super::path_tokens::{self, Word};
use std::path::{Path, PathBuf};

use super::path_tokens::{self, CrMode, Word};

/// The reason recorded for a read word whose expanded value cannot be proven to
/// remain in the workspace.
Expand Down Expand Up @@ -59,6 +61,9 @@ pub(super) struct ReadVerdict {
/// Path spellings to confine with the write-target proof and the
/// working-directory candidate union.
pub candidates: Vec<ReadCandidate>,
/// `/`-less static words the caller re-checks against the working-directory
/// candidates when no directory separator names them (Issue #613 design 2).
pub relative_words: Vec<String>,
}

/// Reads the read-path judgement for a command the write guard allowed.
Expand All @@ -67,10 +72,24 @@ pub(super) fn inspect(command: &str) -> ReadVerdict {
// has readable text, so the raw fallback is only a belt-and-braces guard.
let elided = super::super::shell_lexical::strip_comments_and_heredocs(command);
let text = elided.as_deref().unwrap_or(command);
let Some(segments) = path_tokens::read_words(text) else {
let mut verdict = inspect_text(text, CrMode::Separator);
// Issue #613: Bash keeps a carriage return inside the word. Read the text
// that way too and union the verdicts, so an outward path whose spelling
// holds a `\r` is refused when either reading sees it. The two readings
// coincide when the text has no `\r`.
if text.contains('\r') {
merge(&mut verdict, inspect_text(text, CrMode::WordChar));
}
verdict
}

/// The read-path judgement of one carriage-return reading of the elided text.
fn inspect_text(text: &str, cr_mode: CrMode) -> ReadVerdict {
let Some(segments) = path_tokens::read_words(text, cr_mode) else {
return ReadVerdict {
unverifiable: Some("<shell text>".to_string()),
candidates: Vec::new(),
relative_words: Vec::new(),
};
};
let glob_shell_option = uses_glob_shell_option(text);
Expand Down Expand Up @@ -114,11 +133,113 @@ pub(super) fn inspect(command: &str) -> ReadVerdict {
continue;
}
push_static(&mut verdict.candidates, word, index >= leading);
collect_relative_targets(&mut verdict.relative_words, word, index >= leading);
}
}
verdict
}

/// Unions another carriage-return reading into `into`. An unverifiable word in
/// either reading is kept, and the candidates and `/`-less words are combined,
/// so the union never admits more than one reading alone.
fn merge(into: &mut ReadVerdict, other: ReadVerdict) {
if into.unverifiable.is_none() {
into.unverifiable = other.unverifiable;
}
into.candidates.extend(other.candidates);
into.relative_words.extend(other.relative_words);
}

/// Records the `/`-less static words the caller re-checks against the
/// working-directory candidates (Issue #613 design 2). A leading assignment
/// (`NAME=value`) is a variable, not a path; `.`, `..`, an empty word, and a
/// `~`-prefixed word are excluded. The command name and options are not
/// excluded, matching the existing `cd`-relative word check; a word that is not
/// an actual outward symlink costs only one `lstat` and stays allowed.
fn collect_relative_targets(targets: &mut Vec<String>, word: &Word, is_value_word: bool) {
if !is_value_word {
return;
}
if is_relative_target(&word.text) {
targets.push(word.text.clone());
}
// The right-hand side of a non-assignment `NAME=value` word names a read
// path (`dd if=lf`, `grep --file=lf`, `make IN=lf`) even without a `/`.
if let Some(value) = relative_equals_right_hand_side(&word.text) {
targets.push(value.to_string());
}
}

/// Whether a static word can name a `/`-less relative path the caller re-checks.
fn is_relative_target(word: &str) -> bool {
!word.is_empty() && word != "." && word != ".." && !word.starts_with('~') && !word.contains('/')
}

/// The right-hand side of a `NAME=value` word when it is a non-empty, `/`-less
/// spelling. An empty or `/`-containing right side is left to the existing
/// handling.
fn relative_equals_right_hand_side(word: &str) -> Option<&str> {
let (_, value) = word.split_once('=')?;
(!value.is_empty() && !value.contains('/')).then_some(value)
}

/// The first `/`-less static read word that escapes the workspace when joined
/// onto a working-directory candidate (Issue #613 design 2). The literal read
/// proof runs only when the joined path is an existing symlink (or cannot be
/// `lstat`ed for a reason other than a missing name or a non-directory parent),
/// so a word that is not a symlink — an option, a display string, or an
/// `awk -F= '{print $1}'`-style quoted fragment — keeps its existing handling.
///
/// A working directory that cannot be determined is left to the existing
/// relative-candidate logic in `bash.rs`: that path already refuses the words a
/// `cd` could redirect, while this check deliberately walks only the workspace
/// root and the destinations that *can* be determined. Rejecting every
/// `/`-less word of an undecidable walk here would refuse a bare `cd` chain that
/// names no path (Issue #568 `working_directory_caps_candidate_growth`).
pub(super) fn relative_word_rejection(
words: &[String],
root: &Path,
bases: &[String],
) -> Option<String> {
for word in words {
for base in bases {
let joined = join_base(base, word);
if !joined_is_symlink(root, &joined) {
continue;
}
if super::super::path_guard::ensure_bash_read_target(root, &joined).is_err() {
return Some(word.clone());
}
}
}
None
}

/// Whether the joined path is an existing symlink, or failed to `lstat` for a
/// reason other than a missing name or a non-directory ancestor.
fn joined_is_symlink(root: &Path, joined: &str) -> bool {
let candidate = if Path::new(joined).is_absolute() {
PathBuf::from(joined)
} else {
root.join(joined)
};
match std::fs::symlink_metadata(&candidate) {
Ok(metadata) => metadata.file_type().is_symlink(),
Err(error) => {
error.kind() != std::io::ErrorKind::NotFound
&& error.raw_os_error() != Some(libc::ENOTDIR)
}
}
}

fn join_base(base: &str, path: &str) -> String {
if base.is_empty() {
path.to_string()
} else {
format!("{base}/{path}")
}
}

fn push_static(candidates: &mut Vec<ReadCandidate>, word: &Word, equals_rhs_is_candidate: bool) {
let mut produced = Vec::new();
if word.text.starts_with('/') {
Expand Down Expand Up @@ -256,6 +377,10 @@ mod tests {
"frontend",
"dir1",
"dir2",
// Issue #613: an inside directory whose name holds a carriage return.
// Only the WordChar reading reaches it, and a nested outward symlink
// below it is invisible to every other candidate.
"cr\rsub",
] {
std::fs::create_dir_all(root.join(directory)).unwrap();
}
Expand Down Expand Up @@ -283,6 +408,20 @@ mod tests {
std::os::unix::fs::symlink(dir.path().join("missing-target"), root.join("dangling"))
.unwrap();
std::os::unix::fs::symlink(root.join("sub"), root.join("sub/loop")).unwrap();
std::os::unix::fs::symlink(root.join("sub"), root.join("inlink")).unwrap();
// Issue #613 problem 1: outward symlinks whose name holds a carriage
// return. Bash keeps the `\r` inside the word; the historical reading
// splits on it and misses the symlink.
std::os::unix::fs::symlink(&outside, root.join("sub/cr\rlink")).unwrap();
std::os::unix::fs::symlink(&outside, root.join("cr\rdir")).unwrap();
std::os::unix::fs::symlink(outside.join("secret"), root.join("lfcr\r")).unwrap();
// Issue #613 problem 2: a `/`-less name can itself be an outward symlink.
std::os::unix::fs::symlink(&outside, root.join("lf")).unwrap();
std::os::unix::fs::symlink(&outside, root.join("space lf")).unwrap();
std::os::unix::fs::symlink(&outside, root.join("wide\u{3000}lf")).unwrap();
// Issue #613: an outward symlink inside the CR-named directory above, so
// only the merged WordChar candidate reaches it (`cd cr␍sub && cat esc`).
std::os::unix::fs::symlink(&outside, root.join("cr\rsub/esc")).unwrap();
let root = root.canonicalize().unwrap();
Fixture { _dir: dir, root }
}
Expand Down Expand Up @@ -335,9 +474,41 @@ mod tests {
assert!(seen >= 20, "the decision table is too small: {seen}");
}

/// Issue #613 design 2 "存在しない・ディレクトリでない以外の理由で lstat に
/// 失敗したら、確かめる": an `lstat` that fails for any reason other than a
/// missing name or a non-directory ancestor still needs the literal proof. A
/// component longer than `NAME_MAX` fails with `ENAMETOOLONG`, and narrowing
/// the check to `NotFound` would drop that path.
#[test]
fn joined_is_symlink_treats_other_lstat_failures_as_needing_proof() {
let fixture = fixture();
let root = &fixture.root;
let over_long = "a".repeat(300);
assert!(
super::joined_is_symlink(root, &over_long),
"an over-long name (ENAMETOOLONG) must reach the literal proof"
);
assert!(
!super::joined_is_symlink(root, "no-such-name-f613"),
"a missing name is not a symlink"
);
assert!(
!super::joined_is_symlink(root, "a.txt"),
"a regular file is not a symlink"
);
assert!(
super::joined_is_symlink(root, "lf"),
"an existing symlink needs the proof"
);
}

#[test]
fn read_candidates_keeps_static_word_metadata() {
let parsed = super::path_tokens::read_words("echo $HOME && head sub/link/secret").unwrap();
let parsed = super::path_tokens::read_words(
"echo $HOME && head sub/link/secret",
super::CrMode::Separator,
)
.unwrap();
// The dynamic word and the static relative word are both present: the
// static word is not dropped because a sibling word is dynamic.
let words = parsed.into_iter().flatten().collect::<Vec<_>>();
Expand Down Expand Up @@ -387,7 +558,8 @@ mod tests {
/// `/abs/x$?`) and can change the decision. This is a read-only check.
#[test]
fn read_candidates_keep_exit_status_in_the_word_text() {
let parsed = super::path_tokens::read_words("echo /abs/x$?").unwrap();
let parsed =
super::path_tokens::read_words("echo /abs/x$?", super::CrMode::Separator).unwrap();
let word = &parsed[0][1];
assert_eq!(word.text, "/abs/x$?");
assert!(word.dynamic && word.simple_variable);
Expand Down
Loading
Loading