fix: escape control characters in filenames

This commit is contained in:
sxyazi 2025-12-04 15:41:41 +08:00
parent c6e03e91d2
commit 180b94fb94
No known key found for this signature in database
14 changed files with 207 additions and 74 deletions

View file

@ -1,11 +1,11 @@
use std::{mem, path::MAIN_SEPARATOR_STR};
use std::mem;
use anyhow::Result;
use yazi_fs::{CWD, path::expand_url, provider::{DirReader, FileHolder}};
use yazi_fs::{CWD, path::clean_url, provider::{DirReader, FileHolder}};
use yazi_macro::{act, render, succ};
use yazi_parser::cmp::{CmpItem, ShowOpt, TriggerOpt};
use yazi_proxy::CmpProxy;
use yazi_shared::{AnyAsciiChar, data::Data, natsort, path::{PathBufDyn, PathDyn, PathLike}, scheme::{SchemeCow, SchemeLike}, strand::{AsStrand, StrandLike}, url::{UrlBuf, UrlCow, UrlLike}};
use yazi_shared::{AnyAsciiChar, data::Data, natsort, path::{AsPath, PathBufDyn, PathCow, PathDyn, PathLike}, scheme::{SchemeCow, SchemeLike}, strand::{AsStrand, StrandLike}, url::{UrlBuf, UrlCow, UrlLike}};
use yazi_vfs::provider;
use crate::{Actor, Ctx};
@ -74,24 +74,25 @@ impl Trigger {
return None; // We don't autocomplete a `~`, but `~/`
}
let cwd = CWD.load();
let abs = if !path.is_absolute() && cwd.scheme().covariant(&scheme) {
cwd.loc().try_join(&path).ok()?.into()
} else {
PathCow::from(&path)
};
let sep = if cfg!(windows) {
AnyAsciiChar::new(b"/\\").unwrap()
} else {
AnyAsciiChar::new(b"/").unwrap()
};
Some(match path.rsplit_pred(sep) {
Some((p, c)) if p.is_empty() => {
let root = PathDyn::with_str(scheme.kind(), MAIN_SEPARATOR_STR);
(UrlCow::try_from((scheme, root)).ok()?.into_owned(), c.into())
}
Some((p, c)) => (expand_url(UrlCow::try_from((scheme, p)).ok()?), c.into()),
None if CWD.load().scheme().covariant(&scheme) => (CWD.load().as_ref().clone(), path.into()),
None => {
let empty = PathDyn::with_str(scheme.kind(), "");
(UrlCow::try_from((scheme, empty)).ok()?.into_owned(), path.into())
}
})
let child = path.rsplit_pred(sep).map_or(path.as_path(), |(_, c)| c);
let parent =
PathDyn::with(scheme.kind(), abs.encoded_bytes().strip_suffix(child.encoded_bytes())?)
.ok()?;
Some((clean_url(UrlCow::try_from((scheme, parent)).ok()?), child.into()))
}
}
@ -130,11 +131,11 @@ mod tests {
compare("/foo/bar", "/foo/", "bar");
compare("///foo/bar", "/foo/", "bar");
CWD.set(&"sftp://test/".parse::<UrlBuf>().unwrap(), || {});
compare("sftp://test/a", "sftp://test/", "a");
compare("sftp://test//a", "sftp://test:0//", "a");
compare("sftp://test2/a", "sftp://test2/", "a");
compare("sftp://test2//a", "sftp://test2:0//", "a");
CWD.set(&"sftp://test".parse::<UrlBuf>().unwrap(), || {});
compare("sftp://test/a", "sftp://test/.", "a");
compare("sftp://test//a", "sftp://test//", "a");
compare("sftp://test2/a", "sftp://test2/.", "a");
compare("sftp://test2//a", "sftp://test2//", "a");
}
#[cfg(windows)]

View file

@ -5,7 +5,7 @@ use tokio::pin;
use tokio_stream::{StreamExt, wrappers::UnboundedReceiverStream};
use yazi_config::popup::InputCfg;
use yazi_dds::Pubsub;
use yazi_fs::{File, FilesOp, path::expand_url};
use yazi_fs::{File, FilesOp, path::{clean_url, expand_url}};
use yazi_macro::{act, err, render, succ};
use yazi_parser::mgr::CdOpt;
use yazi_proxy::{CmpProxy, InputProxy, MgrProxy};
@ -71,6 +71,7 @@ impl Cd {
Ok(s) => {
let Ok(url) = UrlBuf::try_from(s).map(expand_url) else { return };
let Ok(url) = provider::absolute(&url).await else { return };
let url = clean_url(url);
let Ok(file) = File::new(&url).await else { return };
if file.is_dir() {

View file

@ -21,7 +21,7 @@ fn clean_path_impl(path: PathDyn, base: usize, trail: usize) -> (PathBufDyn, usi
macro_rules! push {
($i:ident, $c:ident) => {{
out.push($c);
out.push(($i, $c));
if $i >= base {
uri_count += 1;
}
@ -31,12 +31,25 @@ fn clean_path_impl(path: PathDyn, base: usize, trail: usize) -> (PathBufDyn, usi
}};
}
macro_rules! pop {
() => {{
if let Some((i, _)) = out.pop() {
if i >= base {
uri_count -= 1;
}
if i >= trail {
urn_count -= 1;
}
}
}};
}
for (i, c) in path.components().enumerate() {
match c {
CurDir => {}
ParentDir => match out.last() {
ParentDir => match out.last().map(|(_, c)| c) {
Some(RootDir) => {}
Some(Normal(_)) => _ = out.pop(),
Some(Normal(_)) => pop!(),
None | Some(CurDir) | Some(ParentDir) | Some(Prefix(_)) => push!(i, c),
},
c => push!(i, c),
@ -47,7 +60,8 @@ fn clean_path_impl(path: PathDyn, base: usize, trail: usize) -> (PathBufDyn, usi
let path = if out.is_empty() {
PathBufDyn::with_str(kind, ".")
} else {
PathBufDyn::from_components(kind, out).expect("components with same kind")
PathBufDyn::from_components(kind, out.into_iter().map(|(_, c)| c))
.expect("components with same kind")
};
(path, uri_count, urn_count)
@ -70,10 +84,11 @@ mod tests {
("archive://:3:2//../../tmp/test.zip/foo/bar", "archive://:3:2//tmp/test.zip/foo/bar"),
("archive://:3:2//tmp/../../test.zip/foo/bar", "archive://:3:2//test.zip/foo/bar"),
("archive://:4:2//tmp/test.zip/../../foo/bar", "archive://:2:2//foo/bar"),
("archive://:5:2//tmp/test.zip/../../foo/bar", "archive://:3:2//foo/bar"),
("archive://:4:4//tmp/test.zip/foo/bar/../../", "archive://:1:1//tmp/test.zip"),
("archive://:5:4//tmp/test.zip/foo/bar/../../", "archive://:2:1//tmp/test.zip"),
("archive://:5:2//tmp/test.zip/../../foo/bar", "archive://:2:2//foo/bar"),
("archive://:4:4//tmp/test.zip/foo/bar/../../", "archive:////tmp/test.zip"),
("archive://:5:4//tmp/test.zip/foo/bar/../../", "archive://:1//tmp/test.zip"),
("archive://:4:4//tmp/test.zip/foo/bar/../../../", "archive:////tmp"),
("sftp://test//root/.config/yazi/../../Downloads", "sftp://test//root/Downloads"),
];
for (input, expected) in cases {

View file

@ -31,7 +31,7 @@ function Entity:prefix()
end
function Entity:highlights()
local name = self._file.name:gsub("\r", "?", 1)
local name = ui.printable(self._file.name)
local highlights = self._file:highlights()
if not highlights or #highlights == 0 then
return name

View file

@ -64,7 +64,7 @@ function Status:name()
return ""
end
return " " .. h.name:gsub("\r", "?", 1)
return " " .. ui.printable(h.name)
end
function Status:perm()

View file

@ -1,19 +1,23 @@
use std::borrow::Cow;
use mlua::{AnyUserData, ExternalError, IntoLua, Lua, ObjectLike, Table, Value};
use tracing::error;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use yazi_binding::{Composer, ComposerGet, ComposerSet, Permit, PermitRef, elements::{Line, Rect, Span}};
use yazi_config::LAYOUT;
use yazi_proxy::{AppProxy, HIDER};
use yazi_shared::replace_to_printable;
pub fn compose() -> Composer<ComposerGet, ComposerSet> {
fn get(lua: &Lua, key: &[u8]) -> mlua::Result<Value> {
match key {
b"area" => area(lua)?,
b"hide" => hide(lua)?,
b"width" => width(lua)?,
b"printable" => printable(lua)?,
b"redraw" => redraw(lua)?,
b"render" => render(lua)?,
b"truncate" => truncate(lua)?,
b"width" => width(lua)?,
_ => return Ok(Value::Nil),
}
.into_lua(lua)
@ -54,28 +58,12 @@ pub(super) fn hide(lua: &Lua) -> mlua::Result<Value> {
f.into_lua(lua)
}
pub(super) fn width(lua: &Lua) -> mlua::Result<Value> {
let f = lua.create_function(|_, v: Value| match v {
Value::String(s) => {
let (mut acc, b) = (0, s.as_bytes());
for c in b.utf8_chunks() {
acc += c.valid().width();
if !c.invalid().is_empty() {
acc += 1;
}
}
Ok(acc)
pub(super) fn printable(lua: &Lua) -> mlua::Result<Value> {
let f = lua.create_function(|lua, s: mlua::String| {
match replace_to_printable(&*s.as_bytes(), false, 1, true) {
Cow::Borrowed(_) => s.into_lua(lua),
Cow::Owned(new) => new.into_lua(lua),
}
Value::UserData(ud) => {
if let Ok(line) = ud.borrow::<Line>() {
Ok(line.width())
} else if let Ok(span) = ud.borrow::<Span>() {
Ok(span.width())
} else {
Err("expected a string, Line, or Span".into_lua_err())?
}
}
_ => Err("expected a string, Line, or Span".into_lua_err())?,
})?;
f.into_lua(lua)
@ -176,6 +164,33 @@ pub(super) fn truncate(lua: &Lua) -> mlua::Result<Value> {
f.into_lua(lua)
}
pub(super) fn width(lua: &Lua) -> mlua::Result<Value> {
let f = lua.create_function(|_, v: Value| match v {
Value::String(s) => {
let (mut acc, b) = (0, s.as_bytes());
for c in b.utf8_chunks() {
acc += c.valid().width();
if !c.invalid().is_empty() {
acc += 1;
}
}
Ok(acc)
}
Value::UserData(ud) => {
if let Ok(line) = ud.borrow::<Line>() {
Ok(line.width())
} else if let Ok(span) = ud.borrow::<Span>() {
Ok(span.width())
} else {
Err("expected a string, Line, or Span".into_lua_err())?
}
}
_ => Err("expected a string, Line, or Span".into_lua_err())?,
})?;
f.into_lua(lua)
}
#[cfg(test)]
mod tests {
use mlua::{Lua, chunk};

View file

@ -6,7 +6,7 @@ use syntect::{LoadingError, dumps, easy::HighlightLines, highlighting::{self, Th
use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader};
use yazi_config::{THEME, YAZI, preview::PreviewWrap};
use yazi_fs::provider::{Provider, local::Local};
use yazi_shared::{Ids, errors::PeekError, replace_to_printable};
use yazi_shared::{Ids, errors::PeekError, push_printable_char};
static INCR: Ids = Ids::new();
static SYNTECT: OnceLock<(Theme, SyntaxSet)> = OnceLock::new();
@ -95,7 +95,7 @@ impl Highlighter {
}
Ok(if plain {
Text::from(replace_to_printable(&after, YAZI.preview.tab_size))
Text::from(Self::merge_highlight_lines(&after, YAZI.preview.tab_size))
} else {
Self::highlight_with(before, after, syntax.unwrap()).await?
})
@ -203,6 +203,16 @@ impl Highlighter {
*b = b'\n';
}
}
fn merge_highlight_lines(s: &[String], tab_size: u8) -> String {
let mut buf = Vec::new();
buf.reserve_exact(s.iter().map(|s| s.len()).sum::<usize>() | 15);
for &b in s.iter().flat_map(|s| s.as_bytes()) {
push_printable_char(&mut buf, b, true, tab_size, false);
}
unsafe { String::from_utf8_unchecked(buf) }
}
}
impl Highlighter {

View file

@ -94,26 +94,48 @@ pub fn replace_vec_cow<'a>(v: &'a [u8], from: &[u8], to: &[u8]) -> Cow<'a, [u8]>
Cow::Owned(out)
}
pub fn replace_to_printable(s: &[String], tab_size: u8) -> String {
let mut buf = Vec::new();
buf.try_reserve_exact(s.iter().map(|s| s.len()).sum::<usize>() | 15).unwrap_or_else(|_| panic!());
pub fn replace_to_printable(b: &[u8], lf: bool, tab_size: u8, replacement: bool) -> Cow<'_, [u8]> {
// Fast path to skip over printable chars at the beginning of the string
let printable_len = b.iter().take_while(|&&c| !c.is_ascii_control()).count();
if printable_len >= b.len() {
return Cow::Borrowed(b);
}
for &b in s.iter().flat_map(|s| s.as_bytes()) {
match b {
b'\n' => buf.push(b'\n'),
b'\t' => {
buf.extend((0..tab_size).map(|_| b' '));
}
b'\0'..=b'\x1F' => {
let (printable, rest) = b.split_at(printable_len);
let mut out = Vec::new();
out.reserve_exact(b.len() | 15);
out.extend_from_slice(printable);
for &c in rest {
push_printable_char(&mut out, c, lf, tab_size, replacement);
}
Cow::Owned(out)
}
#[inline]
pub fn push_printable_char(buf: &mut Vec<u8>, c: u8, lf: bool, tab_size: u8, replacement: bool) {
match c {
b'\n' if lf => buf.push(b'\n'),
b'\t' => {
buf.extend((0..tab_size).map(|_| b' '));
}
b'\0'..=b'\x1F' => {
if replacement {
buf.extend_from_slice(&[0xef, 0xbf, 0xbd]);
} else {
buf.push(b'^');
buf.push(b + b'@');
buf.push(c + b'@');
}
0x7f => {
}
0x7f => {
if replacement {
buf.extend_from_slice(&[0xef, 0xbf, 0xbd]);
} else {
buf.push(b'^');
buf.push(b'?');
}
_ => buf.push(b),
}
_ => buf.push(c),
}
unsafe { String::from_utf8_unchecked(buf) }
}

View file

@ -107,7 +107,22 @@ impl<'p> LocAbleImpl<'p> for &'p std::path::Path {
where
T: AsStrandView<'a, Self::Strand<'a>>,
{
self.strip_prefix(base.as_strand_view()).ok()
use std::path::is_separator;
let p = self.strip_prefix(base.as_strand_view()).ok()?;
let mut b = p.as_encoded_bytes();
if b.last().is_none_or(|&c| !is_separator(c as char)) || p.parent().is_none() {
return Some(p);
}
while let [head @ .., last] = b
&& is_separator(*last as char)
{
b = head;
}
Some(unsafe { Self::from_encoded_bytes_unchecked(b) })
}
fn to_path_buf(self) -> Self::Owned { self.to_path_buf() }
@ -139,7 +154,18 @@ impl<'p> LocAbleImpl<'p> for &'p typed_path::UnixPath {
where
T: AsStrandView<'a, Self::Strand<'a>>,
{
self.strip_prefix(base.as_strand_view()).ok()
let p = self.strip_prefix(base.as_strand_view()).ok()?;
let mut b = p.as_bytes();
if b.last().is_none_or(|&c| c != b'/') || p.parent().is_none() {
return Some(p);
}
while let [head @ .., b'/'] = b {
b = head;
}
Some(typed_path::UnixPath::new(b))
}
fn to_path_buf(self) -> Self::Owned { self.to_path_buf() }

View file

@ -85,8 +85,8 @@ where
{
let path = path.as_path_view();
let Some(name) = path.file_name() else {
let uri = path.strip_prefix(P::empty()).unwrap().len();
return Self { inner: path, uri, urn: 0, _phantom: PhantomData };
let p = path.strip_prefix(P::empty()).unwrap();
return Self { inner: p, uri: p.len(), urn: 0, _phantom: PhantomData };
};
let name_len = name.len();
@ -244,3 +244,38 @@ where
loc
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_with() -> Result<()> {
let cases = [
// Relative paths
("tmp/test.zip/foo/bar", 3, 2, "test.zip/foo/bar", "foo/bar"),
("tmp/test.zip/foo/bar/", 3, 2, "test.zip/foo/bar", "foo/bar"),
// Absolute paths
("/tmp/test.zip/foo/bar", 3, 2, "test.zip/foo/bar", "foo/bar"),
("/tmp/test.zip/foo/bar/", 3, 2, "test.zip/foo/bar", "foo/bar"),
// Relative path with parent components
("tmp/test.zip/foo/bar/../..", 5, 4, "test.zip/foo/bar/../..", "foo/bar/../.."),
("tmp/test.zip/foo/bar/../../", 5, 4, "test.zip/foo/bar/../..", "foo/bar/../.."),
// Absolute path with parent components
("/tmp/test.zip/foo/bar/../..", 5, 4, "test.zip/foo/bar/../..", "foo/bar/../.."),
("/tmp/test.zip/foo/bar/../../", 5, 4, "test.zip/foo/bar/../..", "foo/bar/../.."),
];
for (path, uri, urn, expect_uri, expect_urn) in cases {
let loc = Loc::with(std::path::Path::new(path), uri, urn)?;
assert_eq!(loc.uri().to_str().unwrap(), expect_uri);
assert_eq!(loc.urn().to_str().unwrap(), expect_urn);
let loc = Loc::with(typed_path::UnixPath::new(path), uri, urn)?;
assert_eq!(loc.uri().to_str().unwrap(), expect_uri);
assert_eq!(loc.urn().to_str().unwrap(), expect_urn);
}
Ok(())
}
}

View file

@ -19,6 +19,10 @@ impl From<PathBufDyn> for PathCow<'_> {
fn from(value: PathBufDyn) -> Self { Self::Owned(value) }
}
impl<'a> From<&'a PathCow<'_>> for PathCow<'a> {
fn from(value: &'a PathCow<'_>) -> Self { Self::Borrowed(value.as_path()) }
}
impl From<PathCow<'_>> for PathBufDyn {
fn from(value: PathCow<'_>) -> Self { value.into_owned() }
}

View file

@ -98,6 +98,10 @@ impl AsStrand for PathCow<'_> {
}
}
impl AsStrand for &PathCow<'_> {
fn as_strand(&self) -> Strand<'_> { (**self).as_strand() }
}
impl AsStrand for Strand<'_> {
fn as_strand(&self) -> Strand<'_> { *self }
}

View file

@ -109,7 +109,7 @@ impl<'a> Strand<'a> {
let (skip, rest) = bytes.split_at(skip_len);
let mut out = Vec::new();
out.try_reserve_exact(bytes.len()).unwrap_or_else(|_| panic!());
out.reserve_exact(bytes.len());
out.extend(skip);
for &b in rest {

View file

@ -19,7 +19,7 @@ impl Transliterator for &[u8] {
// but instead of `+ 16` uses `| 15` to stay in the smallest allocation bucket
// for short strings
let mut out = String::new();
out.try_reserve_exact(self.len() | 15).unwrap_or_else(|_| panic!());
out.reserve_exact(self.len() | 15);
out.push_str(unsafe { str::from_utf8_unchecked(ascii) });
for c in String::from_utf8_lossy(rest).chars() {