Skip to content

Commit dccc9b2

Browse files
authored
fix: escape control characters in filenames (#3400)
1 parent c6e03e9 commit dccc9b2

File tree

16 files changed

+370
-184
lines changed

16 files changed

+370
-184
lines changed

Cargo.lock

Lines changed: 155 additions & 102 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ futures = "0.3.31"
3333
globset = "0.4.18"
3434
hashbrown = { version = "0.16.1", features = [ "serde" ] }
3535
indexmap = { version = "2.12.1", features = [ "serde" ] }
36-
libc = "0.2.177"
36+
libc = "0.2.178"
3737
lru = "0.16.2"
3838
mlua = { version = "0.11.5", features = [ "anyhow", "async", "error-send", "lua54", "macros", "serde" ] }
3939
objc2 = "0.6.3"
@@ -44,7 +44,7 @@ percent-encoding = "2.3.2"
4444
rand = { version = "0.9.2", default-features = false, features = [ "os_rng", "small_rng", "std" ] }
4545
ratatui = { version = "0.29.0", features = [ "unstable-rendered-line-info", "unstable-widget-ref" ] }
4646
regex = "1.12.2"
47-
russh = { version = "0.54.6", default-features = false, features = [ "ring", "rsa" ] }
47+
russh = { version = "0.55.0", default-features = false, features = [ "ring", "rsa" ] }
4848
scopeguard = "1.2.0"
4949
serde = { version = "1.0.228", features = [ "derive" ] }
5050
serde_json = "1.0.145"

yazi-actor/src/cmp/trigger.rs

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
use std::{mem, path::MAIN_SEPARATOR_STR};
1+
use std::mem;
22

33
use anyhow::Result;
4-
use yazi_fs::{CWD, path::expand_url, provider::{DirReader, FileHolder}};
4+
use yazi_fs::{CWD, path::clean_url, provider::{DirReader, FileHolder}};
55
use yazi_macro::{act, render, succ};
66
use yazi_parser::cmp::{CmpItem, ShowOpt, TriggerOpt};
77
use yazi_proxy::CmpProxy;
8-
use yazi_shared::{AnyAsciiChar, data::Data, natsort, path::{PathBufDyn, PathDyn, PathLike}, scheme::{SchemeCow, SchemeLike}, strand::{AsStrand, StrandLike}, url::{UrlBuf, UrlCow, UrlLike}};
8+
use yazi_shared::{AnyAsciiChar, data::Data, natsort, path::{AsPath, PathBufDyn, PathCow, PathDyn, PathLike}, scheme::{SchemeCow, SchemeLike}, strand::{AsStrand, StrandLike}, url::{UrlBuf, UrlCow, UrlLike}};
99
use yazi_vfs::provider;
1010

1111
use crate::{Actor, Ctx};
@@ -74,24 +74,25 @@ impl Trigger {
7474
return None; // We don't autocomplete a `~`, but `~/`
7575
}
7676

77+
let cwd = CWD.load();
78+
let abs = if !path.is_absolute() && cwd.scheme().covariant(&scheme) {
79+
cwd.loc().try_join(&path).ok()?.into()
80+
} else {
81+
PathCow::from(&path)
82+
};
83+
7784
let sep = if cfg!(windows) {
7885
AnyAsciiChar::new(b"/\\").unwrap()
7986
} else {
8087
AnyAsciiChar::new(b"/").unwrap()
8188
};
8289

83-
Some(match path.rsplit_pred(sep) {
84-
Some((p, c)) if p.is_empty() => {
85-
let root = PathDyn::with_str(scheme.kind(), MAIN_SEPARATOR_STR);
86-
(UrlCow::try_from((scheme, root)).ok()?.into_owned(), c.into())
87-
}
88-
Some((p, c)) => (expand_url(UrlCow::try_from((scheme, p)).ok()?), c.into()),
89-
None if CWD.load().scheme().covariant(&scheme) => (CWD.load().as_ref().clone(), path.into()),
90-
None => {
91-
let empty = PathDyn::with_str(scheme.kind(), "");
92-
(UrlCow::try_from((scheme, empty)).ok()?.into_owned(), path.into())
93-
}
94-
})
90+
let child = path.rsplit_pred(sep).map_or(path.as_path(), |(_, c)| c);
91+
let parent =
92+
PathDyn::with(scheme.kind(), abs.encoded_bytes().strip_suffix(child.encoded_bytes())?)
93+
.ok()?;
94+
95+
Some((clean_url(UrlCow::try_from((scheme, parent)).ok()?), child.into()))
9596
}
9697
}
9798

@@ -130,11 +131,11 @@ mod tests {
130131
compare("/foo/bar", "/foo/", "bar");
131132
compare("///foo/bar", "/foo/", "bar");
132133

133-
CWD.set(&"sftp://test/".parse::<UrlBuf>().unwrap(), || {});
134-
compare("sftp://test/a", "sftp://test/", "a");
135-
compare("sftp://test//a", "sftp://test:0//", "a");
136-
compare("sftp://test2/a", "sftp://test2/", "a");
137-
compare("sftp://test2//a", "sftp://test2:0//", "a");
134+
CWD.set(&"sftp://test".parse::<UrlBuf>().unwrap(), || {});
135+
compare("sftp://test/a", "sftp://test/.", "a");
136+
compare("sftp://test//a", "sftp://test//", "a");
137+
compare("sftp://test2/a", "sftp://test2/.", "a");
138+
compare("sftp://test2//a", "sftp://test2//", "a");
138139
}
139140

140141
#[cfg(windows)]

yazi-actor/src/mgr/cd.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use tokio::pin;
55
use tokio_stream::{StreamExt, wrappers::UnboundedReceiverStream};
66
use yazi_config::popup::InputCfg;
77
use yazi_dds::Pubsub;
8-
use yazi_fs::{File, FilesOp, path::expand_url};
8+
use yazi_fs::{File, FilesOp, path::{clean_url, expand_url}};
99
use yazi_macro::{act, err, render, succ};
1010
use yazi_parser::mgr::CdOpt;
1111
use yazi_proxy::{CmpProxy, InputProxy, MgrProxy};
@@ -71,6 +71,7 @@ impl Cd {
7171
Ok(s) => {
7272
let Ok(url) = UrlBuf::try_from(s).map(expand_url) else { return };
7373
let Ok(url) = provider::absolute(&url).await else { return };
74+
let url = clean_url(url);
7475

7576
let Ok(file) = File::new(&url).await else { return };
7677
if file.is_dir() {

yazi-fs/src/path/clean.rs

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ fn clean_path_impl(path: PathDyn, base: usize, trail: usize) -> (PathBufDyn, usi
2121

2222
macro_rules! push {
2323
($i:ident, $c:ident) => {{
24-
out.push($c);
24+
out.push(($i, $c));
2525
if $i >= base {
2626
uri_count += 1;
2727
}
@@ -31,12 +31,25 @@ fn clean_path_impl(path: PathDyn, base: usize, trail: usize) -> (PathBufDyn, usi
3131
}};
3232
}
3333

34+
macro_rules! pop {
35+
() => {{
36+
if let Some((i, _)) = out.pop() {
37+
if i >= base {
38+
uri_count -= 1;
39+
}
40+
if i >= trail {
41+
urn_count -= 1;
42+
}
43+
}
44+
}};
45+
}
46+
3447
for (i, c) in path.components().enumerate() {
3548
match c {
3649
CurDir => {}
37-
ParentDir => match out.last() {
50+
ParentDir => match out.last().map(|(_, c)| c) {
3851
Some(RootDir) => {}
39-
Some(Normal(_)) => _ = out.pop(),
52+
Some(Normal(_)) => pop!(),
4053
None | Some(CurDir) | Some(ParentDir) | Some(Prefix(_)) => push!(i, c),
4154
},
4255
c => push!(i, c),
@@ -47,7 +60,8 @@ fn clean_path_impl(path: PathDyn, base: usize, trail: usize) -> (PathBufDyn, usi
4760
let path = if out.is_empty() {
4861
PathBufDyn::with_str(kind, ".")
4962
} else {
50-
PathBufDyn::from_components(kind, out).expect("components with same kind")
63+
PathBufDyn::from_components(kind, out.into_iter().map(|(_, c)| c))
64+
.expect("components with same kind")
5165
};
5266

5367
(path, uri_count, urn_count)
@@ -70,10 +84,11 @@ mod tests {
7084
("archive://:3:2//../../tmp/test.zip/foo/bar", "archive://:3:2//tmp/test.zip/foo/bar"),
7185
("archive://:3:2//tmp/../../test.zip/foo/bar", "archive://:3:2//test.zip/foo/bar"),
7286
("archive://:4:2//tmp/test.zip/../../foo/bar", "archive://:2:2//foo/bar"),
73-
("archive://:5:2//tmp/test.zip/../../foo/bar", "archive://:3:2//foo/bar"),
74-
("archive://:4:4//tmp/test.zip/foo/bar/../../", "archive://:1:1//tmp/test.zip"),
75-
("archive://:5:4//tmp/test.zip/foo/bar/../../", "archive://:2:1//tmp/test.zip"),
87+
("archive://:5:2//tmp/test.zip/../../foo/bar", "archive://:2:2//foo/bar"),
88+
("archive://:4:4//tmp/test.zip/foo/bar/../../", "archive:////tmp/test.zip"),
89+
("archive://:5:4//tmp/test.zip/foo/bar/../../", "archive://:1//tmp/test.zip"),
7690
("archive://:4:4//tmp/test.zip/foo/bar/../../../", "archive:////tmp"),
91+
("sftp://test//root/.config/yazi/../../Downloads", "sftp://test//root/Downloads"),
7792
];
7893

7994
for (input, expected) in cases {

yazi-plugin/preset/components/entity.lua

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,22 +31,22 @@ function Entity:prefix()
3131
end
3232

3333
function Entity:highlights()
34-
local name = self._file.name:gsub("\r", "?", 1)
34+
local name, p = self._file.name, ui.printable
3535
local highlights = self._file:highlights()
3636
if not highlights or #highlights == 0 then
37-
return name
37+
return p(name)
3838
end
3939

4040
local spans, last = {}, 0
4141
for _, h in ipairs(highlights) do
4242
if h[1] > last then
43-
spans[#spans + 1] = name:sub(last + 1, h[1])
43+
spans[#spans + 1] = p(name:sub(last + 1, h[1]))
4444
end
45-
spans[#spans + 1] = ui.Span(name:sub(h[1] + 1, h[2])):style(th.mgr.find_keyword)
45+
spans[#spans + 1] = ui.Span(p(name:sub(h[1] + 1, h[2]))):style(th.mgr.find_keyword)
4646
last = h[2]
4747
end
4848
if last < #name then
49-
spans[#spans + 1] = name:sub(last + 1)
49+
spans[#spans + 1] = p(name:sub(last + 1))
5050
end
5151
return ui.Line(spans)
5252
end

yazi-plugin/preset/components/status.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ function Status:name()
6464
return ""
6565
end
6666

67-
return " " .. h.name:gsub("\r", "?", 1)
67+
return " " .. ui.printable(h.name)
6868
end
6969

7070
function Status:perm()

yazi-plugin/src/elements/elements.rs

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
1+
use std::borrow::Cow;
2+
13
use mlua::{AnyUserData, ExternalError, IntoLua, Lua, ObjectLike, Table, Value};
24
use tracing::error;
35
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
46
use yazi_binding::{Composer, ComposerGet, ComposerSet, Permit, PermitRef, elements::{Line, Rect, Span}};
57
use yazi_config::LAYOUT;
68
use yazi_proxy::{AppProxy, HIDER};
9+
use yazi_shared::replace_to_printable;
710

811
pub fn compose() -> Composer<ComposerGet, ComposerSet> {
912
fn get(lua: &Lua, key: &[u8]) -> mlua::Result<Value> {
1013
match key {
1114
b"area" => area(lua)?,
1215
b"hide" => hide(lua)?,
13-
b"width" => width(lua)?,
16+
b"printable" => printable(lua)?,
1417
b"redraw" => redraw(lua)?,
1518
b"render" => render(lua)?,
1619
b"truncate" => truncate(lua)?,
20+
b"width" => width(lua)?,
1721
_ => return Ok(Value::Nil),
1822
}
1923
.into_lua(lua)
@@ -54,28 +58,12 @@ pub(super) fn hide(lua: &Lua) -> mlua::Result<Value> {
5458
f.into_lua(lua)
5559
}
5660

57-
pub(super) fn width(lua: &Lua) -> mlua::Result<Value> {
58-
let f = lua.create_function(|_, v: Value| match v {
59-
Value::String(s) => {
60-
let (mut acc, b) = (0, s.as_bytes());
61-
for c in b.utf8_chunks() {
62-
acc += c.valid().width();
63-
if !c.invalid().is_empty() {
64-
acc += 1;
65-
}
66-
}
67-
Ok(acc)
68-
}
69-
Value::UserData(ud) => {
70-
if let Ok(line) = ud.borrow::<Line>() {
71-
Ok(line.width())
72-
} else if let Ok(span) = ud.borrow::<Span>() {
73-
Ok(span.width())
74-
} else {
75-
Err("expected a string, Line, or Span".into_lua_err())?
76-
}
77-
}
78-
_ => Err("expected a string, Line, or Span".into_lua_err())?,
61+
pub(super) fn printable(lua: &Lua) -> mlua::Result<Value> {
62+
let f = lua.create_function(|lua, s: mlua::String| {
63+
Ok(match replace_to_printable(&*s.as_bytes(), false, 1, true) {
64+
Cow::Borrowed(_) => s,
65+
Cow::Owned(new) => lua.create_string(&new)?,
66+
})
7967
})?;
8068

8169
f.into_lua(lua)
@@ -176,6 +164,33 @@ pub(super) fn truncate(lua: &Lua) -> mlua::Result<Value> {
176164
f.into_lua(lua)
177165
}
178166

167+
pub(super) fn width(lua: &Lua) -> mlua::Result<Value> {
168+
let f = lua.create_function(|_, v: Value| match v {
169+
Value::String(s) => {
170+
let (mut acc, b) = (0, s.as_bytes());
171+
for c in b.utf8_chunks() {
172+
acc += c.valid().width();
173+
if !c.invalid().is_empty() {
174+
acc += 1;
175+
}
176+
}
177+
Ok(acc)
178+
}
179+
Value::UserData(ud) => {
180+
if let Ok(line) = ud.borrow::<Line>() {
181+
Ok(line.width())
182+
} else if let Ok(span) = ud.borrow::<Span>() {
183+
Ok(span.width())
184+
} else {
185+
Err("expected a string, Line, or Span".into_lua_err())?
186+
}
187+
}
188+
_ => Err("expected a string, Line, or Span".into_lua_err())?,
189+
})?;
190+
191+
f.into_lua(lua)
192+
}
193+
179194
#[cfg(test)]
180195
mod tests {
181196
use mlua::{Lua, chunk};

yazi-plugin/src/external/highlighter.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use syntect::{LoadingError, dumps, easy::HighlightLines, highlighting::{self, Th
66
use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader};
77
use yazi_config::{THEME, YAZI, preview::PreviewWrap};
88
use yazi_fs::provider::{Provider, local::Local};
9-
use yazi_shared::{Ids, errors::PeekError, replace_to_printable};
9+
use yazi_shared::{Ids, errors::PeekError, push_printable_char};
1010

1111
static INCR: Ids = Ids::new();
1212
static SYNTECT: OnceLock<(Theme, SyntaxSet)> = OnceLock::new();
@@ -95,7 +95,7 @@ impl Highlighter {
9595
}
9696

9797
Ok(if plain {
98-
Text::from(replace_to_printable(&after, YAZI.preview.tab_size))
98+
Text::from(Self::merge_highlight_lines(&after, YAZI.preview.tab_size))
9999
} else {
100100
Self::highlight_with(before, after, syntax.unwrap()).await?
101101
})
@@ -203,6 +203,16 @@ impl Highlighter {
203203
*b = b'\n';
204204
}
205205
}
206+
207+
fn merge_highlight_lines(s: &[String], tab_size: u8) -> String {
208+
let mut buf = Vec::new();
209+
buf.reserve_exact(s.iter().map(|s| s.len()).sum::<usize>() | 15);
210+
211+
for &b in s.iter().flat_map(|s| s.as_bytes()) {
212+
push_printable_char(&mut buf, b, true, tab_size, false);
213+
}
214+
unsafe { String::from_utf8_unchecked(buf) }
215+
}
206216
}
207217

208218
impl Highlighter {

yazi-shared/src/chars.rs

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -94,26 +94,48 @@ pub fn replace_vec_cow<'a>(v: &'a [u8], from: &[u8], to: &[u8]) -> Cow<'a, [u8]>
9494
Cow::Owned(out)
9595
}
9696

97-
pub fn replace_to_printable(s: &[String], tab_size: u8) -> String {
98-
let mut buf = Vec::new();
99-
buf.try_reserve_exact(s.iter().map(|s| s.len()).sum::<usize>() | 15).unwrap_or_else(|_| panic!());
100-
101-
for &b in s.iter().flat_map(|s| s.as_bytes()) {
102-
match b {
103-
b'\n' => buf.push(b'\n'),
104-
b'\t' => {
105-
buf.extend((0..tab_size).map(|_| b' '));
106-
}
107-
b'\0'..=b'\x1F' => {
97+
pub fn replace_to_printable(b: &[u8], lf: bool, tab_size: u8, replacement: bool) -> Cow<'_, [u8]> {
98+
// Fast path to skip over printable chars at the beginning of the string
99+
let printable_len = b.iter().take_while(|&&c| !c.is_ascii_control()).count();
100+
if printable_len >= b.len() {
101+
return Cow::Borrowed(b);
102+
}
103+
104+
let (printable, rest) = b.split_at(printable_len);
105+
106+
let mut out = Vec::new();
107+
out.reserve_exact(b.len() | 15);
108+
out.extend_from_slice(printable);
109+
110+
for &c in rest {
111+
push_printable_char(&mut out, c, lf, tab_size, replacement);
112+
}
113+
Cow::Owned(out)
114+
}
115+
116+
#[inline]
117+
pub fn push_printable_char(buf: &mut Vec<u8>, c: u8, lf: bool, tab_size: u8, replacement: bool) {
118+
match c {
119+
b'\n' if lf => buf.push(b'\n'),
120+
b'\t' => {
121+
buf.extend((0..tab_size).map(|_| b' '));
122+
}
123+
b'\0'..=b'\x1F' => {
124+
if replacement {
125+
buf.extend_from_slice(&[0xef, 0xbf, 0xbd]);
126+
} else {
108127
buf.push(b'^');
109-
buf.push(b + b'@');
128+
buf.push(c + b'@');
110129
}
111-
0x7f => {
130+
}
131+
0x7f => {
132+
if replacement {
133+
buf.extend_from_slice(&[0xef, 0xbf, 0xbd]);
134+
} else {
112135
buf.push(b'^');
113136
buf.push(b'?');
114137
}
115-
_ => buf.push(b),
116138
}
139+
_ => buf.push(c),
117140
}
118-
unsafe { String::from_utf8_unchecked(buf) }
119141
}

0 commit comments

Comments
 (0)