From 180b94fb94c480496661ea91045cf22da828b15a Mon Sep 17 00:00:00 2001 From: sxyazi Date: Thu, 4 Dec 2025 15:41:41 +0800 Subject: [PATCH] fix: escape control characters in filenames --- yazi-actor/src/cmp/trigger.rs | 41 ++++++++-------- yazi-actor/src/mgr/cd.rs | 3 +- yazi-fs/src/path/clean.rs | 29 +++++++++--- yazi-plugin/preset/components/entity.lua | 2 +- yazi-plugin/preset/components/status.lua | 2 +- yazi-plugin/src/elements/elements.rs | 59 +++++++++++++++--------- yazi-plugin/src/external/highlighter.rs | 14 +++++- yazi-shared/src/chars.rs | 50 ++++++++++++++------ yazi-shared/src/loc/able.rs | 30 +++++++++++- yazi-shared/src/loc/loc.rs | 39 +++++++++++++++- yazi-shared/src/path/cow.rs | 4 ++ yazi-shared/src/strand/conversion.rs | 4 ++ yazi-shared/src/strand/strand.rs | 2 +- yazi-shared/src/translit/traits.rs | 2 +- 14 files changed, 207 insertions(+), 74 deletions(-) diff --git a/yazi-actor/src/cmp/trigger.rs b/yazi-actor/src/cmp/trigger.rs index 07f192ff..91e18739 100644 --- a/yazi-actor/src/cmp/trigger.rs +++ b/yazi-actor/src/cmp/trigger.rs @@ -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::().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::().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)] diff --git a/yazi-actor/src/mgr/cd.rs b/yazi-actor/src/mgr/cd.rs index 0f855dbc..1e0a6287 100644 --- a/yazi-actor/src/mgr/cd.rs +++ b/yazi-actor/src/mgr/cd.rs @@ -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() { diff --git a/yazi-fs/src/path/clean.rs b/yazi-fs/src/path/clean.rs index a5b9bc29..7fdac319 100644 --- a/yazi-fs/src/path/clean.rs +++ b/yazi-fs/src/path/clean.rs @@ -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 { diff --git a/yazi-plugin/preset/components/entity.lua b/yazi-plugin/preset/components/entity.lua index 7a287128..abfa98b9 100644 --- a/yazi-plugin/preset/components/entity.lua +++ b/yazi-plugin/preset/components/entity.lua @@ -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 diff --git a/yazi-plugin/preset/components/status.lua b/yazi-plugin/preset/components/status.lua index 45249606..347495f7 100644 --- a/yazi-plugin/preset/components/status.lua +++ b/yazi-plugin/preset/components/status.lua @@ -64,7 +64,7 @@ function Status:name() return "" end - return " " .. h.name:gsub("\r", "?", 1) + return " " .. ui.printable(h.name) end function Status:perm() diff --git a/yazi-plugin/src/elements/elements.rs b/yazi-plugin/src/elements/elements.rs index 6196f4d0..f114fcc4 100644 --- a/yazi-plugin/src/elements/elements.rs +++ b/yazi-plugin/src/elements/elements.rs @@ -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 { fn get(lua: &Lua, key: &[u8]) -> mlua::Result { 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 { f.into_lua(lua) } -pub(super) fn width(lua: &Lua) -> mlua::Result { - 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 { + 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::() { - Ok(line.width()) - } else if let Ok(span) = ud.borrow::() { - 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 { f.into_lua(lua) } +pub(super) fn width(lua: &Lua) -> mlua::Result { + 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::() { + Ok(line.width()) + } else if let Ok(span) = ud.borrow::() { + 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}; diff --git a/yazi-plugin/src/external/highlighter.rs b/yazi-plugin/src/external/highlighter.rs index b352a055..e79cbbec 100644 --- a/yazi-plugin/src/external/highlighter.rs +++ b/yazi-plugin/src/external/highlighter.rs @@ -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::() | 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 { diff --git a/yazi-shared/src/chars.rs b/yazi-shared/src/chars.rs index 590067b6..40bfe8f9 100644 --- a/yazi-shared/src/chars.rs +++ b/yazi-shared/src/chars.rs @@ -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::() | 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, 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) } } diff --git a/yazi-shared/src/loc/able.rs b/yazi-shared/src/loc/able.rs index f25b9a3e..a87e17a1 100644 --- a/yazi-shared/src/loc/able.rs +++ b/yazi-shared/src/loc/able.rs @@ -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() } diff --git a/yazi-shared/src/loc/loc.rs b/yazi-shared/src/loc/loc.rs index 59492801..d377b396 100644 --- a/yazi-shared/src/loc/loc.rs +++ b/yazi-shared/src/loc/loc.rs @@ -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(()) + } +} diff --git a/yazi-shared/src/path/cow.rs b/yazi-shared/src/path/cow.rs index 49b7ef4f..fe3f1073 100644 --- a/yazi-shared/src/path/cow.rs +++ b/yazi-shared/src/path/cow.rs @@ -19,6 +19,10 @@ impl From 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> for PathBufDyn { fn from(value: PathCow<'_>) -> Self { value.into_owned() } } diff --git a/yazi-shared/src/strand/conversion.rs b/yazi-shared/src/strand/conversion.rs index 846f139c..8e46e012 100644 --- a/yazi-shared/src/strand/conversion.rs +++ b/yazi-shared/src/strand/conversion.rs @@ -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 } } diff --git a/yazi-shared/src/strand/strand.rs b/yazi-shared/src/strand/strand.rs index a1cc5711..daddd691 100644 --- a/yazi-shared/src/strand/strand.rs +++ b/yazi-shared/src/strand/strand.rs @@ -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 { diff --git a/yazi-shared/src/translit/traits.rs b/yazi-shared/src/translit/traits.rs index 590746f9..71d01824 100644 --- a/yazi-shared/src/translit/traits.rs +++ b/yazi-shared/src/translit/traits.rs @@ -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() {