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
30 changes: 28 additions & 2 deletions src/cli/bssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,13 +299,18 @@ pub struct Cli {
)]
pub quiet: bool,

#[arg(short = 't', long = "tty", help = "Force pseudo-terminal allocation")]
#[arg(
short = 't',
long = "tty",
overrides_with_all = ["force_tty", "no_tty"],
help = "Force pseudo-terminal allocation"
)]
pub force_tty: bool,

#[arg(
short = 'T',
long = "no-tty",
conflicts_with = "force_tty",
overrides_with_all = ["force_tty", "no_tty"],
help = "Disable pseudo-terminal allocation"
)]
pub no_tty: bool,
Expand Down Expand Up @@ -842,6 +847,27 @@ mod tests {
);
}

#[test]
fn openssh_tty_flags_accept_repetition_and_last_flag_wins() {
for (args, expected) in [
(vec!["bssh", "-t", "target"], (true, false, false)),
(vec!["bssh", "-tt", "target"], (true, false, false)),
(vec!["bssh", "-ttq", "target"], (true, false, true)),
(vec!["bssh", "-T", "target"], (false, true, false)),
(vec!["bssh", "-tT", "target"], (false, true, false)),
(vec!["bssh", "-Tt", "target"], (true, false, false)),
] {
let cli = Cli::try_parse_from(&args)
.unwrap_or_else(|error| panic!("failed to parse {args:?}: {error}"));

assert_eq!(
(cli.force_tty, cli.no_tty, cli.quiet),
expected,
"unexpected tty mode for {args:?}"
);
}
}

#[test]
fn ssh_option_lookup_is_case_insensitive_and_first_value_wins() {
let cli = Cli::try_parse_from([
Expand Down
26 changes: 15 additions & 11 deletions src/ssh/config_cache/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use super::config::CacheConfig;
use super::entry::CacheEntry;
use super::stats::CacheStats;
use crate::ssh::SshConfig;
use crate::ssh::ssh_config::diagnostic::escape_path;
use anyhow::{Context, Result};
use lru::LruCache;
use std::path::{Path, PathBuf};
Expand Down Expand Up @@ -67,27 +68,30 @@ impl SshConfigCache {
let path_ref = path.as_ref();
let path = tokio::fs::canonicalize(path_ref)
.await
.with_context(|| format!("Failed to canonicalize path: {}", path_ref.display()))?;
.with_context(|| format!("Failed to canonicalize path: {}", escape_path(path_ref)))?;

// Check if file exists and get its modification time
let file_metadata = tokio::fs::metadata(&path)
.await
.with_context(|| format!("Failed to read file metadata: {}", path.display()))?;
.with_context(|| format!("Failed to read file metadata: {}", escape_path(&path)))?;

let current_mtime = file_metadata
.modified()
.with_context(|| format!("Failed to get modification time: {}", path.display()))?;
.with_context(|| format!("Failed to get modification time: {}", escape_path(&path)))?;

// Try to get from cache first
if let Some(config) = self.try_get_cached(&path, current_mtime)? {
return Ok(config);
}

// Cache miss - load from file
trace!("Cache miss for SSH config: {}", path.display());
let config = SshConfig::load_from_file(&path)
.await
.with_context(|| format!("Failed to load SSH config from file: {}", path.display()))?;
trace!("Cache miss for SSH config: {}", escape_path(&path));
let config = SshConfig::load_from_file(&path).await.with_context(|| {
format!(
"Failed to load SSH config from file: {}",
escape_path(&path)
)
})?;

// Store in cache
if let Err(e) = self.put(path, config.clone(), current_mtime) {
Expand Down Expand Up @@ -120,7 +124,7 @@ impl SshConfigCache {
if let Some(entry) = cache.get_mut(path) {
// Check if entry is expired
if entry.is_expired(self.config.ttl) {
debug!("SSH config cache entry expired: {}", path.display());
debug!("SSH config cache entry expired: {}", escape_path(path));
cache.pop(path);

let mut stats = self.stats.write().map_err(|e| {
Expand All @@ -132,7 +136,7 @@ impl SshConfigCache {

// Check if entry is stale (file was modified)
if entry.is_stale(current_mtime) {
debug!("SSH config cache entry stale: {}", path.display());
debug!("SSH config cache entry stale: {}", escape_path(path));
cache.pop(path);

let mut stats = self.stats.write().map_err(|e| {
Expand All @@ -153,7 +157,7 @@ impl SshConfigCache {
stats.hits += 1;
}

trace!("SSH config cache hit: {}", path.display());
trace!("SSH config cache hit: {}", escape_path(path));
return Ok(Some(config));
}

Expand Down Expand Up @@ -185,7 +189,7 @@ impl SshConfigCache {
stats.current_entries = cache.len();
}

trace!("SSH config cached: {}", path.display());
trace!("SSH config cached: {}", escape_path(&path));
Ok(())
}

Expand Down
61 changes: 61 additions & 0 deletions src/ssh/ssh_config/diagnostic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright 2025 Lablup Inc. and Jeongkyu Shin
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use std::{borrow::Cow, path::Path};

pub(crate) fn escape_field(value: &str) -> Cow<'_, str> {
if !value.chars().any(char::is_control) {
return Cow::Borrowed(value);
}

let mut escaped = String::with_capacity(value.len());
for character in value.chars() {
if character.is_control() {
escaped.extend(character.escape_default());
} else {
escaped.push(character);
}
}
Cow::Owned(escaped)
}

pub(crate) fn escape_path(path: &Path) -> Cow<'_, str> {
match path.to_string_lossy() {
Cow::Borrowed(value) => escape_field(value),
Cow::Owned(value) => Cow::Owned(escape_field(&value).into_owned()),
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn fields_escape_controls_but_preserve_printable_unicode() {
assert_eq!(
escape_field("경로/é/λ\r\n\t\u{1b}\u{7f}\u{85}"),
"경로/é/λ\\r\\n\\t\\u{1b}\\u{7f}\\u{85}"
);
assert!(matches!(escape_field("경로/é/λ"), Cow::Borrowed(_)));
}

#[test]
fn paths_use_the_same_control_escaping_contract() {
assert_eq!(escape_path(Path::new("safe\nFORGED")), "safe\\nFORGED");
assert!(matches!(
escape_path(Path::new("printable/경로")),
Cow::Borrowed(_)
));
}
}
24 changes: 16 additions & 8 deletions src/ssh/ssh_config/include/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ use anyhow::{Context, Result};
use std::collections::HashSet;
use std::path::{Path, PathBuf};

use super::diagnostic::{escape_field, escape_path};

mod resolver;
mod validation;

Expand Down Expand Up @@ -95,7 +97,7 @@ impl IncludeContext {
// Canonicalize and cache the result
let canonical = path
.canonicalize()
.with_context(|| format!("Failed to canonicalize path: {}", path.display()))?;
.with_context(|| format!("Failed to canonicalize path: {}", escape_path(path)))?;
self.canonical_cache
.insert(path.to_path_buf(), canonical.clone());
canonical
Expand All @@ -115,7 +117,7 @@ impl IncludeContext {
if self.visited.contains(&canonical_str) {
anyhow::bail!(
"Include cycle detected: {} has already been processed",
path.display()
escape_path(path)
);
}

Expand Down Expand Up @@ -166,7 +168,7 @@ pub async fn resolve_includes(config_path: &Path, content: &str) -> Result<Vec<I
config_path.canonicalize().with_context(|| {
format!(
"Failed to canonicalize main config path: {}",
config_path.display()
escape_path(config_path)
)
})?
} else {
Expand Down Expand Up @@ -214,16 +216,16 @@ async fn process_file_with_includes(
.with_context(|| {
format!(
"Failed to resolve Include pattern '{}' at line {} in {}",
pattern,
escape_field(pattern),
line_number,
file_path.display()
escape_path(file_path)
)
})?;

// Process each resolved file recursively
for include_path in resolved_files {
context.enter_include(&include_path).with_context(|| {
format!("Failed to include file: {}", include_path.display())
format!("Failed to include file: {}", escape_path(&include_path))
})?;

// Read with timeout to prevent hanging on network filesystems
Expand All @@ -233,10 +235,16 @@ async fn process_file_with_includes(
)
.await
.map_err(|_| {
anyhow::anyhow!("Timeout reading include file: {}", include_path.display())
anyhow::anyhow!(
"Timeout reading include file: {}",
escape_path(&include_path)
)
})?
.with_context(|| {
format!("Failed to read include file: {}", include_path.display())
format!(
"Failed to read include file: {}",
escape_path(&include_path)
)
})?;

// Recursively process the included file (use Box::pin to avoid stack overflow)
Expand Down
31 changes: 22 additions & 9 deletions src/ssh/ssh_config/include/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use anyhow::{Context, Result};
use std::path::PathBuf;

use super::super::diagnostic::{escape_field, escape_path};
use super::super::path::expand_path_internal;
#[cfg(not(test))]
use super::validation::is_path_allowed;
Expand Down Expand Up @@ -82,7 +83,7 @@ pub async fn resolve_include_pattern(
// Convert to string for glob
let pattern_str = search_path
.to_str()
.ok_or_else(|| anyhow::anyhow!("Invalid UTF-8 in path: {search_path:?}"))?;
.ok_or_else(|| anyhow::anyhow!("Invalid UTF-8 in path: {}", escape_path(&search_path)))?;

// Additional validation after expansion
validate_glob_pattern(pattern_str)?;
Expand All @@ -99,12 +100,12 @@ pub async fn resolve_include_pattern(
};

for entry in glob::glob_with(pattern_str, glob_options)
.with_context(|| format!("Invalid glob pattern: {pattern_str}"))?
.with_context(|| format!("Invalid glob pattern: {}", escape_field(pattern_str)))?
{
if files.len() >= MAX_GLOB_RESULTS {
anyhow::bail!(
"Glob pattern '{pattern}' matched too many files (>{MAX_GLOB_RESULTS}). \
Please use a more specific pattern."
"Glob pattern '{}' matched too many files (>{MAX_GLOB_RESULTS}). Please use a more specific pattern.",
escape_field(pattern)
);
}

Expand All @@ -118,7 +119,11 @@ pub async fn resolve_include_pattern(
Ok(c) => c,
Err(_) if !path.exists() => continue, // Skip non-existent files
Err(e) => {
tracing::debug!("Failed to canonicalize {}: {}", path.display(), e);
tracing::debug!(
"Failed to canonicalize {}: {}",
escape_path(&path),
escape_field(&e.to_string())
);
continue;
}
};
Expand All @@ -127,7 +132,7 @@ pub async fn resolve_include_pattern(
if !is_path_allowed(&canonical) {
tracing::warn!(
"Glob result {} escapes allowed directories, skipping",
path.display()
escape_path(&path)
);
continue;
}
Expand All @@ -144,13 +149,21 @@ pub async fn resolve_include_pattern(
}
}
Err(e) => {
tracing::debug!("Failed to get metadata for {}: {}", path.display(), e);
tracing::debug!(
"Failed to get metadata for {}: {}",
escape_path(&path),
escape_field(&e.to_string())
);
}
}
}
Err(e) => {
// Log glob errors but continue
tracing::warn!("Error processing glob pattern '{}': {}", pattern_str, e);
tracing::warn!(
"Error processing glob pattern '{}': {}",
escape_field(pattern_str),
escape_field(&e.to_string())
);
}
}
}
Expand All @@ -162,7 +175,7 @@ pub async fn resolve_include_pattern(
if files.is_empty() && !pattern.contains('*') && !pattern.contains('?') {
tracing::debug!(
"Include pattern '{}' matched no files (this may be intentional)",
pattern
escape_field(pattern)
);
}

Expand Down
Loading
Loading