Repository: pkolaczk/fclones (language: rust)

Issue:
File not found (os error 2) when removing duplicates with non-Unicode symbols in the path name
After fclones scanned and correctly identified the duplicate files I tried to run the command `fclones link <dupes.txt` on a folder mounted with samba on ubuntu 20.04-wsl and I got this error on files with very long names.

I received same error with `fclones move duplicatefolder/ <dupes.txt`

The base filesystem is ZFS on TrueNAS-12.0-U8


```
fclones:  warn: Failed to read metadata of /home/mount/longfoldername/youtube/Kaos/#134 Come temprare l'acciaio e qualche curiosit� (semplice tutorial)/#134 Come temprare l'acciaio e qualche curiosit� (semplice tutorial) (italiano_ASR).srt: No such file or directory (os error 2)
fclones:  warn: Could not determine files to drop in group with hash e85d86851b7fabba21fa20c426304b50 and len 8330: Metadata of some files could not be obtained
```

Could the problem be that the path is too long?
Could it be related to the character `à` that is not recognized correctly?
Could it be an ubuntu and samba problem?

Grazie
Francesco

P.S. I ran this command in a cloned test dataset to avoid data loss of any kind so I'm not worried about any lost data.



Discussion / hints:
The os error 2 translates to ENOENT: Error: No such entry / entity, so fclones can't find the given file. If you can "ls" the file normally then this is probably caused by fclones running `.to_string_lossy()` to deal with invalid Unicode filenames. This call converts the 8-bit `à` to `�`. Working with raw 8-bit sequences everywhere would be possible but is rather tedious.
Here the filename is probably encoded with ISO 8859-1 or -15, try converting it and others to proper Unicode, then it should work.

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 044c043..ab8a411 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -372,8 +372,9 @@ dependencies = [
  "regex",
  "serde",
  "serde_json",
- "shell-words",
+ "serde_test",
  "smallvec",
+ "stfu8",
  "structopt",
  "sysinfo",
  "tempfile",
@@ -909,10 +910,13 @@ dependencies = [
 ]
 
 [[package]]
-name = "shell-words"
-version = "1.0.0"
+name = "serde_test"
+version = "1.0.136"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6fa3938c99da4914afedd13bf3d79bcb6c277d1b2c398d23257a304d9e1b074"
+checksum = "21675ba6f9d97711cc00eee79d8dd7d0a31e571c350fb4d8a7c78f70c0e7b0e9"
+dependencies = [
+ "serde",
+]
 
 [[package]]
 name = "smallvec"
@@ -926,6 +930,16 @@ version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
 
+[[package]]
+name = "stfu8"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "019f0c664fd85d5a87dcfb62b40b691055392a35a6e59f4df83d4b770db7e876"
+dependencies = [
+ "lazy_static",
+ "regex",
+]
+
 [[package]]
 name = "strsim"
 version = "0.8.0"
diff --git a/Cargo.toml b/Cargo.toml
index 24cc581..62be628 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -46,8 +46,8 @@ reflink = "0.1.3"
 regex = "1.4.5"
 serde_json = "1.0"
 serde = { version = "1", features = ["derive"] }
-shell-words = "1.0.0"
 smallvec = "1.6.1"
+stfu8 = "0.2"
 structopt = "0.3.21"
 sysinfo = "0.15.0"
 thread_local = "1.0.1"
@@ -65,6 +65,7 @@ winapi = "0.3.8"
 winapi-util = "0.1.5"
 
 [dev-dependencies]
+serde_test = "1.0"
 tempfile = "3.2.0"
 
 [profile.release]
diff --git a/src/arg.rs b/src/arg.rs
new file mode 100644
index 0000000..31aac4a
--- /dev/null
+++ b/src/arg.rs
@@ -0,0 +1,460 @@
+//! Command line argument parsing and quoting utilities.
+//!
+//! Provides lossless OsString conversions to and from String by shell-like escaping and quoting.
+
+use std::error::Error;
+use std::ffi::{OsStr, OsString};
+use std::fmt::{Debug, Display, Formatter};
+use std::mem;
+
+use itertools::Itertools;
+use serde::de::Visitor;
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+use stfu8::DecodeError;
+
+/// Argument passed to the app
+#[derive(Debug, Eq, PartialEq, Clone)]
+pub struct Arg(OsString);
+
+impl Arg {
+    pub fn from_escaped_string(s: &str) -> Result<Self, DecodeError> {
+        Ok(Arg(from_stfu8(s)?))
+    }
+
+    pub fn to_escaped_string(&self) -> String {
+        to_stfu8(self.0.clone())
+    }
+
+    pub fn quote(&self) -> String {
+        quote(self.0.to_os_string())
+    }
+
+    pub fn as_os_str(&self) -> &OsStr {
+        self.0.as_ref()
+    }
+}
+
+impl AsRef<OsStr> for Arg {
+    fn as_ref(&self) -> &OsStr {
+        self.0.as_os_str()
+    }
+}
+
+impl From<OsString> for Arg {
+    fn from(s: OsString) -> Self {
+        Arg(s)
+    }
+}
+
+impl From<&OsStr> for Arg {
+    fn from(s: &OsStr) -> Self {
+        Arg(OsString::from(s))
+    }
+}
+
+impl From<&str> for Arg {
+    fn from(s: &str) -> Self {
+        Arg(OsString::from(s))
+    }
+}
+
+struct ArgVisitor;
+
+impl Visitor<'_> for ArgVisitor {
+    type Value = Arg;
+
+    fn expecting(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
+        formatter.write_str("an STFU encoded string")
+    }
+
+    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
+    where
+        E: serde::de::Error,
+    {
+        let arg = Arg::from_escaped_string(v).map_err(|e| E::custom(e.to_string()))?;
+        Ok(arg)
+    }
+}
+
+impl Serialize for Arg {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        serializer.serialize_str(self.to_escaped_string().as_str())
+    }
+}
+
+impl<'de> Deserialize<'de> for Arg {
+    fn deserialize<D>(deserializer: D) -> Result<Arg, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        deserializer.deserialize_str(ArgVisitor)
+    }
+}
+
+/// Returns a lossless string representation in [STFU8 format](https://crates.io/crates/stfu8).
+#[cfg(unix)]
+pub fn to_stfu8(s: OsString) -> String {
+    use std::os::unix::ffi::OsStringExt;
+    let raw_path_bytes = s.into_vec();
+    stfu8::encode_u8(&raw_path_bytes)
+}
+
+/// Returns a lossless string representation in [STFU8 format](https://crates.io/crates/stfu8).
+#[cfg(windows)]
+pub fn to_stfu8(s: OsString) -> String {
+    use std::os::windows::ffi::OsStrExt;
+    let raw_path_bytes: Vec<u16> = s.encode_wide().collect();
+    stfu8::encode_u16(&raw_path_bytes)
+}
+
+/// Decodes the path from the string encoded with [`to_stfu8`](OsString::to_stfu8).
+#[cfg(unix)]
+pub fn from_stfu8(encoded: &str) -> Result<OsString, DecodeError> {
+    use std::os::unix::ffi::OsStringExt;
+    let raw_bytes = stfu8::decode_u8(encoded)?;
+    Ok(OsString::from_vec(raw_bytes))
+}
+
+/// Decodes the path from the string encoded with [`to_stfu8`](OsString::to_stfu8).
+#[cfg(windows)]
+pub fn from_stfu8(encoded: &str) -> Result<OsString, DecodeError> {
+    use std::os::windows::ffi::OsStringExt;
+    let raw_bytes = stfu8::decode_u16(encoded)?;
+    Ok(OsString::from_wide(&raw_bytes))
+}
+
+const SPECIAL_CHARS: [char; 25] = [
+    '|', '&', ';', '<', '>', '(', ')', '{', '}', '$', '`', '\\', '\'', '"', ' ', '\t', '*', '?',
+    '+', '[', ']', '#', '˜', '=', '%',
+];
+
+/// Escapes special characters in a string, so that it will retain its literal meaning when used as
+/// a part of command in Unix shell.
+///
+/// It tries to avoid introducing any unnecessary quotes or escape characters, but specifics
+/// regarding quoting style are left unspecified.
+pub fn quote(s: OsString) -> String {
+    let lossy = s.to_string_lossy();
+    if lossy
+        .chars()
+        .any(|c| c < '\u{20}' || c == '\u{7f}' || c == '\u{fffd}')
+    {
+        format!("$'{}'", to_stfu8(s))
+    } else if lossy.chars().any(|c| SPECIAL_CHARS.contains(&c)) {
+        format!("'{}'", lossy.replace('\'', "\\'"))
+    } else {
+        lossy.to_string()
+    }
+}
+
+#[derive(Debug)]
+pub struct ParseError {
+    pub msg: String,
+}
+
+impl ParseError {
+    pub fn new(msg: &str) -> ParseError {
+        ParseError {
+            msg: msg.to_string(),
+        }
+    }
+}
+
+impl Display for ParseError {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}", self.msg)
+    }
+}
+
+impl Error for ParseError {}
+
+enum State {
+    /// Within a delimiter.
+    Delimiter,
+    /// After backslash, but before starting word.
+    Backslash,
+    /// Within an unquoted word.
+    Unquoted,
+    /// After backslash in an unquoted word.
+    UnquotedBackslash,
+    /// Within a single quoted word.
+    SingleQuoted,
+    /// Within a double quoted word.
+    DoubleQuoted,
+    /// After backslash inside a double quoted word.
+    DoubleQuotedBackslash,
+    /// After dollar in an unquoted word.
+    Dollar,
+    /// Within a quoted word preceded by a dollar sign.
+    DollarQuoted,
+    /// After backslash in a dollar-quoted word.
+    DollarQuotedBackslash,
+    /// Inside a comment.
+    Comment,
+}
+
+/// Appends a character to OsString
+fn append(s: &mut OsString, c: char) {
+    let mut buf = [0; 4];
+    let c = c.encode_utf8(&mut buf);
+    s.push(c)
+}
+
+/// Splits command line into separate arguments, in much the same way Unix shell would, but without
+/// many of expansion the shell would perform.
+///
+/// The split functionality is compatible with behaviour of Unix shell, but with word expansions
+/// limited to quote removal, and without special token recognition rules for operators.
+///
+/// The result is exactly the same as one obtained from Unix shell as long as those unsupported
+/// features are not present in input: no operators, no variable assignments, no tilde expansion,
+/// no parameter expansion, no command substitution, no arithmetic expansion, no pathname
+/// expansion.
+///
+/// In case those unsupported shell features are present, the syntax that introduce them is
+/// interpreted literally.
+///
+/// # Errors
+///
+/// When input contains unmatched quote, an error is returned.
+///
+/// # Compatibility with other implementations
+///
+/// It should be fully compatible with g_shell_parse_argv from GLib, except that in GLib
+/// it is an error not to have any words after tokenization.
+///
+/// It is also very close to shlex.split available in Python standard library, when used in POSIX
+/// mode with support for comments. Though, shlex implementation diverges from POSIX, and from
+/// implementation contained herein in three aspects. First, it doesn't support line continuations.
+/// Second, inside double quotes, the backslash characters retains its special meaning as an escape
+/// character only when followed by \\ or \", whereas POSIX specifies that it should retain its
+/// special meaning when followed by: $, \`, \", \\, or a newline. Third, it treats carriage return
+/// as one of delimiters.
+/// ```
+pub fn split(s: &str) -> Result<Vec<Arg>, ParseError> {
+    // Based on shell-words crate by Tomasz Miąsko
+    // Handling of dollar quotes added by Piotr Kołaczkowski
+
+    use State::*;
+
+    let mut words = Vec::new();
+    let mut word = OsString::new();
+
+    let mut pos = 0;
+    let mut dollar_quote_start = 0;
+
+    let mut chars = s.chars();
+    let mut state = Delimiter;
+
+    loop {
+        let c = chars.next();
+        state = match state {
+            Delimiter => match c {
+                None => break,
+                Some('\'') => SingleQuoted,
+                Some('\"') => DoubleQuoted,
+                Some('\\') => Backslash,
+                Some('\t') | Some(' ') | Some('\n') => Delimiter,
+                Some('$') => Dollar,
+                Some('#') => Comment,
+                Some(c) => {
+                    append(&mut word, c);
+                    Unquoted
+                }
+            },
+            Backslash => match c {
+                None => {
+                    append(&mut word, '\\');
+                    words.push(Arg(mem::replace(&mut word, OsString::new())));
+                    break;
+                }
+                Some('\n') => Delimiter,
+                Some(c) => {
+                    append(&mut word, c);
+                    Unquoted
+                }
+            },
+            Unquoted => match c {
+                None => {
+                    words.push(Arg(mem::replace(&mut word, OsString::new())));
+                    break;
+                }
+                Some('\'') => SingleQuoted,
+                Some('\"') => DoubleQuoted,
+                Some('\\') => UnquotedBackslash,
+                Some('$') => Dollar,
+                Some('\t') | Some(' ') | Some('\n') => {
+                    words.push(Arg(mem::replace(&mut word, OsString::new())));
+                    Delimiter
+                }
+                Some(c) => {
+                    append(&mut word, c);
+                    Unquoted
+                }
+            },
+            UnquotedBackslash => match c {
+                None => {
+                    append(&mut word, '\\');
+                    words.push(Arg(mem::replace(&mut word, OsString::new())));
+                    break;
+                }
+                Some('\n') => Unquoted,
+                Some(c) => {
+                    append(&mut word, c);
+                    Unquoted
+                }
+            },
+            SingleQuoted => match c {
+                None => return Err(ParseError::new("Unclosed single quote")),
+                Some('\'') => Unquoted,
+                Some(c) => {
+                    append(&mut word, c);
+                    SingleQuoted
+                }
+            },
+            DoubleQuoted => match c {
+                None => return Err(ParseError::new("Unclosed double quote")),
+                Some('\"') => Unquoted,
+                Some('\\') => DoubleQuotedBackslash,
+                Some(c) => {
+                    append(&mut word, c);
+                    DoubleQuoted
+                }
+            },
+            DoubleQuotedBackslash => match c {
+                None => return Err(ParseError::new("Unexpected end of input")),
+                Some('\n') => DoubleQuoted,
+                Some(c @ '$') | Some(c @ '`') | Some(c @ '"') | Some(c @ '\\') => {
+                    append(&mut word, c);
+                    DoubleQuoted
+                }
+                Some(c) => {
+                    append(&mut word, '\\');
+                    append(&mut word, c);
+                    DoubleQuoted
+                }
+            },
+            Dollar => match c {
+                None => return Err(ParseError::new("Unexpected end of input")),
+                Some('\'') => {
+                    dollar_quote_start = pos + 1;
+                    DollarQuoted
+                }
+                Some(_) => return Err(ParseError::new("Expected single quote")),
+            },
+            DollarQuoted => match c {
+                None => return Err(ParseError::new("Unclosed single quote")),
+                Some('\\') => DollarQuotedBackslash,
+                Some('\'') => {
+                    let quoted_slice = &s[dollar_quote_start..pos];
+                    let decoded = from_stfu8(quoted_slice).map_err(|e| {
+                        ParseError::new(format!("Failed to decode STFU-8 chunk: {}", e).as_str())
+                    })?;
+                    word.push(decoded.as_os_str());
+                    Unquoted
+                }
+                Some(_) => DollarQuoted,
+            },
+            DollarQuotedBackslash => match c {
+                None => return Err(ParseError::new("Unexpected end of input")),
+                Some(_) => DollarQuoted,
+            },
+            Comment => match c {
+                None => break,
+                Some('\n') => Delimiter,
+                Some(_) => Comment,
+            },
+        };
+        pos += 1;
+    }
+
+    Ok(words)
+}
+
+/// Joins multiple command line args into a single-line escaped representation
+pub fn join(args: &[Arg]) -> String {
+    args.iter().map(|arg| arg.quote()).join(" ")
+}
+
+#[cfg(test)]
+mod test {
+    use std::ffi::OsString;
+
+    use crate::arg::{quote, split, Arg};
+
+    #[test]
+    fn quote_no_special_chars() {
+        assert_eq!(quote(OsString::from("abc/def_123.txt")), "abc/def_123.txt");
+    }
+
+    #[test]
+    fn quote_path_with_control_chars() {
+        assert_eq!(quote(OsString::from("a\nb")), "$'a\\nb'");
+        assert_eq!(quote(OsString::from("a\tb")), "$'a\\tb'");
+    }
+
+    #[test]
+    fn quote_path_with_special_chars() {
+        assert_eq!(quote(OsString::from("a b")), "'a b'");
+        assert_eq!(quote(OsString::from("a*b")), "'a*b'");
+        assert_eq!(quote(OsString::from("a?b")), "'a?b'");
+        assert_eq!(quote(OsString::from("$ab")), "'$ab'");
+        assert_eq!(quote(OsString::from("a(b)")), "'a(b)'");
+        assert_eq!(quote(OsString::from("a\\b")), "'a\\b'");
+    }
+
+    #[test]
+    fn split_unquoted_args() {
+        assert_eq!(
+            split("arg1 arg2").unwrap(),
+            vec![Arg::from("arg1"), Arg::from("arg2")]
+        )
+    }
+
+    #[test]
+    fn split_single_quoted_args() {
+        assert_eq!(
+            split("'arg1 with spaces' arg2").unwrap(),
+            vec![Arg::from("arg1 with spaces"), Arg::from("arg2")]
+        )
+    }
+
+    #[test]
+    fn split_doubly_quoted_args() {
+        assert_eq!(
+            split("\"arg1 with spaces\" arg2").unwrap(),
+            vec![Arg::from("arg1 with spaces"), Arg::from("arg2")]
+        )
+    }
+
+    #[test]
+    fn split_quotes_escaping() {
+        assert_eq!(
+            split("\"escaped \\\" quotes\"").unwrap(),
+            vec![Arg::from("escaped \" quotes")]
+        )
+    }
+
+    #[test]
+    fn split_spaces_escaping() {
+        assert_eq!(
+            split("escaped\\ space").unwrap(),
+            vec![Arg::from("escaped space")]
+        )
+    }
+
+    #[test]
+    fn dollar_quoting() {
+        assert_eq!(
+            split("arg1 $'arg2-\\n\\t\\\\' arg3-$'\\x7f'").unwrap(),
+            vec![
+                Arg::from("arg1"),
+                Arg::from("arg2-\n\t\\"),
+                Arg::from("arg3-\x7f")
+            ]
+        )
+    }
+}
diff --git a/src/dedupe.rs b/src/dedupe.rs
index ea2e725..a6c05c1 100644
--- a/src/dedupe.rs
+++ b/src/dedupe.rs
@@ -54,7 +54,7 @@ impl FileMetadata {
         let metadata = fs::symlink_metadata(&path_buf).map_err(|e| {
             io::Error::new(
                 e.kind(),
-                format!("Failed to read metadata of {}: {}", path, e),
+                format!("Failed to read metadata of {}: {}", path.display(), e),
             )
         })?;
         Ok(FileMetadata { path, metadata })
@@ -81,7 +81,7 @@ impl AsPath for FileMetadata {
 
 impl Display for FileMetadata {
     fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
-        f.pad(format!("{}", self.path).as_str())
+        f.pad(self.path.display().as_str())
     }
 }
 
@@ -112,8 +112,12 @@ pub enum FsCommand {
 impl FsCommand {
     pub fn remove(path: &Path) -> io::Result<()> {
         let _ = FileLock::new(path)?;
-        fs::remove_file(path.to_path_buf())
-            .map_err(|e| io::Error::new(e.kind(), format!("Failed to remove file {}: {}", path, e)))
+        fs::remove_file(path.to_path_buf()).map_err(|e| {
+            io::Error::new(
+                e.kind(),
+                format!("Failed to remove file {}: {}", path.display(), e),
+            )
+        })
     }
 
     #[cfg(unix)]
@@ -132,7 +136,9 @@ impl FsCommand {
                 e.kind(),
                 format!(
                     "Failed to create symbolic link {} -> {}: {}",
-                    link, target, e
+                    link.display(),
+                    target.display(),
+                    e
                 ),
             )
         })
@@ -142,7 +148,12 @@ impl FsCommand {
         fs::hard_link(&target.to_path_buf(), &link.to_path_buf()).map_err(|e| {
             io::Error::new(
                 e.kind(),
-                format!("Failed to create hard link {} -> {}: {}", link, target, e),
+                format!(
+                    "Failed to create hard link {} -> {}: {}",
+                    link.display(),
+                    target.display(),
+                    e
+                ),
             )
         })
     }
@@ -165,7 +176,7 @@ impl FsCommand {
         fs::create_dir_all(path.to_path_buf()).map_err(|e| {
             io::Error::new(
                 e.kind(),
-                format!("Failed to create directory {}: {}", path, e),
+                format!("Failed to create directory {}: {}", path.display(), e),
             )
         })
     }
@@ -178,7 +189,7 @@ impl FsCommand {
                 e.kind(),
                 format!(
                     "Failed to rename file from {} to {}: {}",
-                    source,
+                    source.display(),
                     target.display(),
                     e
                 ),
@@ -194,7 +205,7 @@ impl FsCommand {
                 e.kind(),
                 format!(
                     "Failed to copy file from {} to {}: {}",
-                    source,
+                    source.display(),
                     target.display(),
                     e
                 ),
@@ -260,7 +271,9 @@ impl FsCommand {
                 if let Err(remove_err) = Self::unsafe_rename(&tmp, path) {
                     log.warn(format!(
                         "Failed to undo move from {} to {}: {}",
-                        &path, &tmp, remove_err
+                        &path.display(),
+                        &tmp.display(),
+                        remove_err
                     ))
                 }
                 return Err(e);
@@ -268,7 +281,11 @@ impl FsCommand {
         };
         // Cleanup the temp file.
         if let Err(e) = Self::remove(&tmp) {
-            log.warn(format!("Failed to remove temporary {}: {}", &tmp, e))
+            log.warn(format!(
+                "Failed to remove temporary {}: {}",
+                &tmp.display(),
+                e
+            ))
         }
         Ok(result)
     }
@@ -324,41 +341,41 @@ impl FsCommand {
         let mut result = Vec::new();
         match self {
             FsCommand::Remove { file, .. } => {
-                let path = file.path.shell_quote();
+                let path = file.path.quote();
                 result.push(format!("rm {}", path));
             }
             FsCommand::SoftLink { target, link, .. } => {
                 let tmp = Self::temp_file(&link.path);
-                let target = target.path.shell_quote();
-                let link = link.path.shell_quote();
-                result.push(format!("mv {} {}", link, tmp));
+                let target = target.path.quote();
+                let link = link.path.quote();
+                result.push(format!("mv {} {}", link, tmp.quote()));
                 result.push(format!("ln -s {} {}", target, link));
-                result.push(format!("rm {}", tmp));
+                result.push(format!("rm {}", tmp.quote()));
             }
             FsCommand::HardLink { target, link, .. } => {
                 let tmp = Self::temp_file(&link.path);
-                let target = target.path.shell_quote();
-                let link = link.path.shell_quote();
-                result.push(format!("mv {} {}", link, tmp));
+                let target = target.path.quote();
+                let link = link.path.quote();
+                result.push(format!("mv {} {}", link, tmp.quote()));
                 result.push(format!("ln {} {}", target, link));
-                result.push(format!("rm {}", tmp));
+                result.push(format!("rm {}", tmp.quote()));
             }
             FsCommand::RefLink { target, link, .. } => {
                 let tmp = Self::temp_file(&link.path);
-                let target = target.path.shell_quote();
-                let link = link.path.shell_quote();
+                let target = target.path.quote();
+                let link = link.path.quote();
                 // Not really what happens on Linux, there the `mv` is also a reflink.
-                result.push(format!("mv {} {}", link, tmp));
+                result.push(format!("mv {} {}", link, tmp.quote()));
                 result.push(format!("cp --reflink=always {} {}", target, link));
-                result.push(format!("rm {}", tmp));
+                result.push(format!("rm {}", tmp.quote()));
             }
             FsCommand::Move {
                 source,
                 target,
                 use_rename,
             } => {
-                let source = source.path.shell_quote();
-                let target = target.shell_quote();
+                let source = source.path.quote();
+                let target = target.quote();
                 if *use_rename {
                     result.push(format!("mv {} {}", &source, &target));
                 } else {
@@ -375,24 +392,24 @@ impl FsCommand {
         let mut result = Vec::new();
         match self {
             FsCommand::Remove { file, .. } => {
-                let path = file.path.shell_quote();
+                let path = file.path.quote();
                 result.push(format!("del {}", path));
             }
             FsCommand::SoftLink { target, link, .. } => {
                 let tmp = Self::temp_file(&link.path);
-                let target = target.path.shell_quote();
-                let link = link.path.shell_quote();
-                result.push(format!("move {} {}", link, tmp));
+                let target = target.path.quote();
+                let link = link.path.quote();
+                result.push(format!("move {} {}", link, tmp.quote()));
                 result.push(format!("mklink {} {}", target, link));
-                result.push(format!("del {}", tmp));
+                result.push(format!("del {}", tmp.quote()));
             }
             FsCommand::HardLink { target, link, .. } => {
                 let tmp = Self::temp_file(&link.path);
-                let target = target.path.shell_quote();
-                let link = link.path.shell_quote();
-                result.push(format!("move {} {}", link, tmp));
+                let target = target.path.quote();
+                let link = link.path.quote();
+                result.push(format!("move {} {}", link, tmp.quote()));
                 result.push(format!("mklink /H {} {}", target, link));
-                result.push(format!("del {}", tmp));
+                result.push(format!("del {}", tmp.quote()));
             }
             FsCommand::RefLink { target, link, .. } => {
                 result.push(format!(":: deduplicate {} {}", link, target));
@@ -402,8 +419,8 @@ impl FsCommand {
                 target,
                 use_rename,
             } => {
-                let source = source.path.shell_quote();
-                let target = target.shell_quote();
+                let source = source.path.quote();
+                let target = target.quote();
                 if *use_rename {
                     result.push(format!("move {} {}", &source, &target));
                 } else {
@@ -458,7 +475,7 @@ fn was_modified(files: &[FileMetadata], after: DateTime<FixedOffset>, log: &Log)
                 if file_timestamp > after {
                     log.warn(format!(
                         "File {} was updated after {} (at {})",
-                        p,
+                        p.display(),
                         after.format(TIMESTAMP_FMT),
                         file_timestamp.format(TIMESTAMP_FMT)
                     ));
@@ -468,7 +485,8 @@ fn was_modified(files: &[FileMetadata], after: DateTime<FixedOffset>, log: &Log)
             Err(e) => {
                 log.warn(format!(
                     "Failed to read modification time of file {}: {}",
-                    p, e
+                    p.display(),
+                    e
                 ));
                 result = true;
             }
@@ -524,9 +542,13 @@ impl FileSubGroup<FileMetadata> {
     /// Returns the time of the earliest creation of a file in the subgroup
     pub fn created(&self) -> Result<SystemTime, Error> {
         Ok(min_result(self.files.iter().map(|f| {
-            f.metadata
-                .created()
-                .map_err(|e| format!("Failed to read creation time of file {}: {}", f.path, e))
+            f.metadata.created().map_err(|e| {
+                format!(
+                    "Failed to read creation time of file {}: {}",
+                    f.path.display(),
+                    e
+                )
+            })
         }))?
         .unwrap())
     }
@@ -534,9 +556,13 @@ impl FileSubGroup<FileMetadata> {
     /// Returns the time of the latest modification of a file in the subgroup
     pub fn modified(&self) -> Result<SystemTime, Error> {
         Ok(max_result(self.files.iter().map(|f| {
-            f.metadata
-                .modified()
-                .map_err(|e| format!("Failed to read modification time of file {}: {}", f.path, e))
+            f.metadata.modified().map_err(|e| {
+                format!(
+                    "Failed to read modification time of file {}: {}",
+                    f.path.display(),
+                    e
+                )
+            })
         }))?
         .unwrap())
     }
@@ -544,9 +570,13 @@ impl FileSubGroup<FileMetadata> {
     /// Returns the time of the latest access of a file in the subgroup
     pub fn accessed(&self) -> Result<SystemTime, Error> {
         Ok(max_result(self.files.iter().map(|f| {
-            f.metadata
-                .accessed()
-                .map_err(|e| format!("Failed to read access time of file {}: {}", f.path, e))
+            f.metadata.accessed().map_err(|e| {
+                format!(
+                    "Failed to read access time of file {}: {}",
+                    f.path.display(),
+                    e
+                )
+            })
         }))?
         .unwrap())
     }
@@ -614,8 +644,9 @@ impl PartitionedFileGroup {
     fn move_target(target_dir: &Arc<Path>, source_path: &Path) -> Path {
         let root = source_path
             .root()
-            .to_string()
+            .to_string_lossy()
             .replace('/', "")
+            .replace('\\', "")
             .replace(':', "");
         let suffix = source_path.strip_root();
         if root.is_empty() {
@@ -728,7 +759,10 @@ fn partition(
     files.retain(|m| {
         let is_file = m.metadata.is_file();
         if !is_file {
-            log.warn(format!("Skipping file {}: Not a regular file", m.path));
+            log.warn(format!(
+                "Skipping file {}: Not a regular file",
+                m.path.display()
+            ));
         }
         is_file
     });
@@ -740,7 +774,7 @@ fn partition(
         if !len_ok {
             log.warn(format!(
                 "Skipping file {} with length {} different than the group length {}",
-                m.path,
+                m.path.display(),
                 m.metadata.len(),
                 file_len.0,
             ));
diff --git a/src/lib.rs b/src/lib.rs
index be4a8d2..c13c0bc 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -2,7 +2,7 @@ use core::fmt;
 use std::cell::RefCell;
 use std::cmp::{max, min, Reverse};
 use std::collections::HashMap;
-use std::env::{args, current_dir};
+use std::env::{args_os, current_dir};
 use std::ffi::{OsStr, OsString};
 use std::fmt::{Display, Formatter};
 use std::fs::File;
@@ -22,6 +22,7 @@ use serde::*;
 use sysinfo::DiskType;
 use thread_local::ThreadLocal;
 
+use crate::arg::Arg;
 pub use dedupe::{dedupe, log_script, run_script, DedupeOp, DedupeResult};
 
 use crate::config::*;
@@ -44,6 +45,7 @@ pub mod path;
 pub mod progress;
 pub mod report;
 
+mod arg;
 mod dedupe;
 mod device;
 mod group;
@@ -747,7 +749,8 @@ fn handle_fetch_physical_location_err(
         ctx.log.warn(format!(
             "Failed to fetch file extents mapping for file {}: {}. \
             This is generally harmless, but it might decrease random access performance.",
-            file_info.path, error
+            file_info.path.display(),
+            error
         ));
         let err_count = counter.fetch_add(1, Ordering::AcqRel);
         if err_count == MAX_ERR_COUNT_TO_LOG {
@@ -1097,8 +1100,8 @@ pub fn write_report(config: &GroupConfig, log: &Log, groups: &[FileGroup<Path>])
     let header = ReportHeader {
         timestamp: DateTime::from_utc(now.naive_utc(), *now.offset()),
         version: env!("CARGO_PKG_VERSION").to_owned(),
-        command: args().collect(),
-        base_dir: config.base_dir.to_string_lossy().to_string(),
+        command: args_os().map(Arg::from).collect(),
+        base_dir: Path::from(&config.base_dir),
         stats: Some(FileStats {
             group_count: groups.len(),
             total_file_count: total_count,
diff --git a/src/lock.rs b/src/lock.rs
index 82321d2..8366143 100644
--- a/src/lock.rs
+++ b/src/lock.rs
@@ -62,14 +62,17 @@ impl FileLock {
             .create(false)
             .open(&path_buf)
             .map_err(|e| {
-                io::Error::new(e.kind(), format!("Failed to open file {}: {}", path, e))
+                io::Error::new(
+                    e.kind(),
+                    format!("Failed to open file {}: {}", path.display(), e),
+                )
             })?;
 
         #[cfg(unix)]
         if let Err(e) = Self::fcntl_lock(&file) {
             return Err(io::Error::new(
                 e.kind(),
-                format!("Failed to lock file {}: {}", path, e),
+                format!("Failed to lock file {}: {}", path.display(), e),
             ));
         };
 
diff --git a/src/main.rs b/src/main.rs
index 314ff39..025cafe 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2,18 +2,17 @@ use std::collections::HashMap;
 use std::ffi::{OsStr, OsString};
 use std::fs::File;
 use std::io::{stdin, Write};
-use std::path::PathBuf;
 use std::process::exit;
 use std::sync::Arc;
 use std::{fs, io};
 
+use console::style;
 use fallible_iterator::FallibleIterator;
 use itertools::Itertools;
 use rayon::iter::ParallelBridge;
 use regex::Regex;
 use structopt::StructOpt;
 
-use console::style;
 use fclones::config::{Command, Config, DedupeConfig, GroupConfig, Parallelism};
 use fclones::log::Log;
 use fclones::report::{open_report, ReportHeader};
@@ -148,7 +147,7 @@ fn get_command_config(header: &ReportHeader) -> Result<Config, Error> {
     // Configure the same base directory as set when running the previous command.
     // This is important to get the correct input paths.
     if let Command::Group(ref mut group_config) = command.command {
-        group_config.base_dir = PathBuf::from(&header.base_dir)
+        group_config.base_dir = header.base_dir.to_path_buf();
     }
     Ok(command)
 }
diff --git a/src/path.rs b/src/path.rs
index f2b720a..8c6d0fb 100644
--- a/src/path.rs
+++ b/src/path.rs
@@ -2,16 +2,19 @@
 
 use std::ffi::{CStr, CString, OsString};
 use std::fmt;
-use std::fmt::Display;
 use std::hash::Hash;
 use std::path::{Component, PathBuf};
 use std::sync::Arc;
 
 use metrohash::MetroHash128;
 use nom::lib::std::fmt::Formatter;
-use serde::{Serialize, Serializer};
+use serde::de::{Error, Visitor};
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
 use smallvec::SmallVec;
+use stfu8::DecodeError;
 
+use crate::arg;
+use crate::arg::{from_stfu8, to_stfu8};
 use crate::path::string::{c_to_os_str, os_to_c_str};
 
 #[cfg(unix)]
@@ -180,8 +183,29 @@ impl Path {
         self.to_path_buf().to_string_lossy().to_string()
     }
 
-    pub fn display(&self) -> &Self {
-        self
+    /// Returns a lossless string representation in [STFU8 format](https://crates.io/crates/stfu8).
+    pub fn to_escaped_string(&self) -> String {
+        to_stfu8(self.to_path_buf().into_os_string())
+    }
+
+    /// Decodes the path from the string encoded with [`to_escaped_string`](Path::to_escaped_string).
+    pub fn from_escaped_string(encoded: &str) -> Result<Path, DecodeError> {
+        Ok(Path::from(from_stfu8(encoded)?))
+    }
+
+    /// Formats the path in a way that Posix-shell can decode it.
+    /// If the path doesn't contain any special characters, returns it as-is.
+    /// If the path contains special shell characters like '\\' or '*', it is single-quoted.
+    /// This function also takes care of the characters that cannot be represented in UTF-8
+    /// by escaping them with `$'\xXX'` or `$'\uXXXX'` syntax.
+    pub fn quote(&self) -> String {
+        arg::quote(self.to_path_buf().into_os_string())
+    }
+
+    /// Returns a representation suitable for display in the console.
+    /// Control characters like newline or linefeed are escaped.
+    pub fn display(&self) -> String {
+        self.quote()
     }
 
     /// Returns a hash of the full path. Useful for deduplicating paths without making path clones.
@@ -269,25 +293,6 @@ impl Path {
         }
         result
     }
-
-    #[cfg(unix)]
-    pub fn shell_quote(&self) -> String {
-        shell_words::quote(self.to_string().as_str()).into()
-    }
-
-    #[cfg(windows)]
-    pub fn shell_quote(&self) -> String {
-        let s = self.to_string();
-        const SPECIAL_CHARS: [char; 14] = [
-            ' ', '"', '(', ')', '^', '%', '$', '~', '=', '+', '*', '?', '|', '/',
-        ];
-        let needs_quote = s.chars().into_iter().any(|c| SPECIAL_CHARS.contains(&c));
-        if needs_quote {
-            format!("\"{}\"", s)
-        } else {
-            s
-        }
-    }
 }
 
 impl AsRef<Path> for Path {
@@ -309,7 +314,7 @@ where
         let p = p.as_ref();
         let mut components = p.components();
         let mut result = Path::new(component_to_c_string(
-            &components.next().expect("Empty path not supported"),
+            &components.next().unwrap_or(Component::CurDir),
         ));
         for c in components {
             result = Arc::new(result).push(component_to_c_string(&c))
@@ -318,24 +323,44 @@ where
     }
 }
 
-impl Display for Path {
-    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
-        f.pad(format!("{}", self.to_path_buf().display()).as_str())
-    }
-}
-
 impl Serialize for Path {
     fn serialize<S>(&self, serializer: S) -> Result<<S as Serializer>::Ok, <S as Serializer>::Error>
     where
         S: Serializer,
     {
-        serializer.collect_str(self)
+        serializer.collect_str(self.to_escaped_string().as_str())
+    }
+}
+
+struct PathVisitor;
+
+impl Visitor<'_> for PathVisitor {
+    type Value = Path;
+
+    fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
+        formatter.write_str("path string")
+    }
+
+    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
+    where
+        E: Error,
+    {
+        Path::from_escaped_string(v).map_err(|e| E::custom(format!("Invalid path: {}", e)))
+    }
+}
+
+impl<'de> Deserialize<'de> for Path {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        deserializer.deserialize_str(PathVisitor)
     }
 }
 
 impl fmt::Debug for Path {
     fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
-        write!(f, "{}", self.to_path_buf().display())
+        write!(f, "{:?}", self.to_path_buf())
     }
 }
 
@@ -368,6 +393,7 @@ mod string {
 #[cfg(test)]
 mod test {
     use super::*;
+    use serde_test::{assert_ser_tokens, Token};
 
     fn test_convert(s: &str) {
         assert_eq!(PathBuf::from(s), Path::from(s).to_path_buf());
@@ -462,4 +488,21 @@ mod test {
         assert!(Path::from("/foo/bar").is_prefix_of(&Path::from("/foo/bar/baz")));
         assert!(!Path::from("/foo/bar").is_prefix_of(&Path::from("/foo")))
     }
+
+    #[test]
+    fn encode_decode_stfu8() {
+        fn roundtrip(s: &str) {
+            assert_eq!(Path::from_escaped_string(s).unwrap().to_escaped_string(), s)
+        }
+        roundtrip("a/b/c");
+        roundtrip("ą/ś/ć");
+        roundtrip("a \\n b");
+        roundtrip("a \\t b");
+        roundtrip("a \\x7F b");
+    }
+
+    #[test]
+    fn serialize() {
+        assert_ser_tokens(&Path::from("a \n b"), &[Token::String("a \\n b")])
+    }
 }
diff --git a/src/reflink.rs b/src/reflink.rs
index 32ae3a0..adee152 100644
--- a/src/reflink.rs
+++ b/src/reflink.rs
@@ -44,7 +44,11 @@ pub fn reflink(src: &FileMetadata, dest: &FileMetadata, log: &Log) -> io::Result
             let result = metadata
                 .and_then(|metadata| restore_some_metadata(&parent.to_path_buf(), &metadata));
             if let Err(e) = result {
-                log.warn(format!("Failed keep metadata for {}: {}", parent, e))
+                log.warn(format!(
+                    "Failed keep metadata for {}: {}",
+                    parent.display(),
+                    e
+                ))
             }
         }
     }
@@ -70,7 +74,11 @@ fn linux_reflink(src: &FileMetadata, dest: &FileMetadata, log: &Log) -> io::Resu
 
     let remove_temporary = |temporary| {
         if let Err(e) = FsCommand::remove(&temporary) {
-            log.warn(format!("Failed to remove temporary {}: {}", &temporary, e))
+            log.warn(format!(
+                "Failed to remove temporary {}: {}",
+                temporary.display(),
+                e
+            ))
         }
     };
 
@@ -85,7 +93,9 @@ fn linux_reflink(src: &FileMetadata, dest: &FileMetadata, log: &Log) -> io::Resu
             if let Err(remove_err) = FsCommand::unsafe_rename(&tmp, &dest.path) {
                 log.warn(format!(
                     "Failed to undo deduplication from {} to {}: {}",
-                    &dest, &tmp, remove_err
+                    &dest,
+                    tmp.display(),
+                    remove_err
                 ))
             }
             Err(e)
@@ -159,7 +169,12 @@ fn copy_by_reflink(src: &FcPath, dest: &FcPath) -> io::Result<()> {
     reflink::reflink(&src.to_path_buf(), &dest.to_path_buf()).map_err(|e| {
         io::Error::new(
             e.kind(),
-            format!("Failed to deduplicate {} -> {}: {}", dest, src, e),
+            format!(
+                "Failed to deduplicate {} -> {}: {}",
+                dest.display(),
+                src.display(),
+                e
+            ),
         )
     })
 }
diff --git a/src/report.rs b/src/report.rs
index 7b80c20..0581be7 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -3,7 +3,6 @@
 use std::borrow::Borrow;
 use std::cell::Cell;
 use std::cmp::min;
-use std::fmt::Display;
 use std::io;
 use std::io::{BufRead, BufReader, Error, ErrorKind, Read, Write};
 
@@ -15,11 +14,12 @@ use lazy_static::lazy_static;
 use regex::Regex;
 use serde::{Deserialize, Serialize};
 
+use crate::arg::Arg;
 use crate::config::OutputFormat;
 use crate::files::{FileHash, FileLen};
 use crate::path::Path;
 use crate::util::IteratorWrapper;
-use crate::{FileGroup, TIMESTAMP_FMT};
+use crate::{arg, FileGroup, TIMESTAMP_FMT};
 
 /// Describes how many redundant files were found, in how many groups,
 /// how much space can be reclaimed, etc.
@@ -42,9 +42,9 @@ pub struct ReportHeader {
     /// The date and time when the report was produced
     pub timestamp: DateTime<FixedOffset>,
     /// Full shell command containing arguments of the search run that produced the report
-    pub command: Vec<String>,
+    pub command: Vec<Arg>,
     /// Working directory where the fclones command was executed
-    pub base_dir: String,
+    pub base_dir: Path,
     /// Information on the number of duplicate files reported.
     /// This is optional to allow streaming the report out before finding all files in the future.
     pub stats: Option<FileStats>,
@@ -117,20 +117,22 @@ impl<W: Write> ReportWriter<W> {
     ///     /home/pkolaczk/Projekty/fclones/.git/refs/remotes/origin/fix_flock_freebsd
     /// ...
     /// ```
-    pub fn write_as_text<I, G, P>(&mut self, header: &ReportHeader, groups: I) -> io::Result<()>
+    pub fn write_as_text<I, G>(&mut self, header: &ReportHeader, groups: I) -> io::Result<()>
     where
         I: IntoIterator<Item = G>,
-        G: Borrow<FileGroup<P>>,
-        P: Display,
+        G: Borrow<FileGroup<Path>>,
     {
-        let command = shell_words::join(header.command.iter());
+        let command = arg::join(&header.command);
         self.write_header_line(&format!("Report by fclones {}", header.version))?;
         self.write_header_line(&format!(
             "Timestamp: {}",
             header.timestamp.format(TIMESTAMP_FMT)
         ))?;
         self.write_header_line(&format!("Command: {}", command))?;
-        self.write_header_line(&format!("Base dir: {}", header.base_dir))?;
+        self.write_header_line(&format!(
+            "Base dir: {}",
+            header.base_dir.to_escaped_string()
+        ))?;
         if let Some(stats) = &header.stats {
             self.write_header_line(&format!(
                 "Total: {} B ({}) in {} files in {} groups",
@@ -161,7 +163,7 @@ impl<W: Write> ReportWriter<W> {
             let group_header = style(group_header).yellow();
             writeln!(self.out, "{}", group_header.force_styling(self.color),)?;
             for f in g.files.iter() {
-                writeln!(self.out, "    {}", f)?;
+                writeln!(self.out, "    {}", f.to_escaped_string())?;
             }
         }
         Ok(())
@@ -170,16 +172,15 @@ impl<W: Write> ReportWriter<W> {
     /// Writes the report in `fdupes` compatible format.
     /// This is very similar to the TEXT format, but there are no headers
     /// for each group, and groups are separated with empty lines.
-    pub fn write_as_fdupes<I, G, P>(&mut self, _header: &ReportHeader, groups: I) -> io::Result<()>
+    pub fn write_as_fdupes<I, G>(&mut self, _header: &ReportHeader, groups: I) -> io::Result<()>
     where
         I: IntoIterator<Item = G>,
-        G: Borrow<FileGroup<P>>,
-        P: Display,
+        G: Borrow<FileGroup<Path>>,
     {
         for g in groups {
             let g = g.borrow();
             for f in g.files.iter() {
-                writeln!(self.out, "{}", f)?;
+                writeln!(self.out, "{}", f.to_escaped_string())?;
             }
             writeln!(self.out)?;
         }
@@ -195,11 +196,10 @@ impl<W: Write> ReportWriter<W> {
     /// - file hash (may be empty)
     /// - number of files in the group
     /// - file paths - each file in a separate column
-    pub fn write_as_csv<I, G, P>(&mut self, _header: &ReportHeader, groups: I) -> io::Result<()>
+    pub fn write_as_csv<I, G>(&mut self, _header: &ReportHeader, groups: I) -> io::Result<()>
     where
         I: IntoIterator<Item = G>,
-        G: Borrow<FileGroup<P>>,
-        P: Display,
+        G: Borrow<FileGroup<Path>>,
     {
         let mut wtr = csv::WriterBuilder::new()
             .delimiter(b',')
@@ -215,7 +215,7 @@ impl<W: Write> ReportWriter<W> {
             record.push_field(g.file_hash.to_string().as_str());
             record.push_field(g.files.len().to_string().as_str());
             for f in g.files.iter() {
-                record.push_field(format!("{}", f).as_ref());
+                record.push_field(f.to_escaped_string().as_ref());
             }
             wtr.write_record(&record)?;
         }
@@ -280,7 +280,7 @@ impl<W: Write> ReportWriter<W> {
     }
 
     /// Writes the report in the format given by `format` parameter.
-    pub fn write<I, G, P>(
+    pub fn write<I, G>(
         &mut self,
         format: OutputFormat,
         header: &ReportHeader,
@@ -288,8 +288,7 @@ impl<W: Write> ReportWriter<W> {
     ) -> io::Result<()>
     where
         I: IntoIterator<Item = G>,
-        G: Borrow<FileGroup<P>> + Serialize,
-        P: Display + Serialize,
+        G: Borrow<FileGroup<Path>> + Serialize,
     {
         match format {
             OutputFormat::Default => self.write_as_text(header, groups),
@@ -402,7 +401,13 @@ where
                     format!("Path expected: {}", path_str),
                 ));
             }
-            paths.push(Path::from(path_str.trim()));
+            let path = Path::from_escaped_string(path_str.trim()).map_err(|e| {
+                Error::new(
+                    ErrorKind::InvalidData,
+                    format!("Invalid path {}: {}", path_str, e),
+                )
+            })?;
+            paths.push(path);
         }
         Ok(paths)
     }
@@ -536,13 +541,14 @@ impl<R: BufRead + Send + 'static> ReportReader for TextReportReader<R> {
             .swap_remove(0);
         let timestamp = Self::parse_timestamp(&timestamp, "timestamp")?;
         let command = self.read_extract(&COMMAND_RE, "command")?.swap_remove(0);
-        let command = shell_words::split(&command).map_err(|e| {
+        let command = arg::split(&command).map_err(|e| {
             Error::new(
                 ErrorKind::InvalidData,
                 format!("Malformed header: Failed to parse command arguments: {}", e),
             )
         })?;
         let base_dir = self.read_extract(&BASE_DIR_RE, "base dir")?.swap_remove(0);
+        let base_dir = Path::from(base_dir);
 
         let stats = self.read_extract(&TOTAL_RE, "total file statistics")?;
         let total_file_size = Self::parse_file_len(stats.get(0), "total file size")?;
@@ -610,7 +616,18 @@ impl ReportReader for JsonReportReader {
             Ok(FileGroup {
                 file_len: g.file_len,
                 file_hash: g.file_hash,
-                files: g.files.iter().map(|s| Path::from(s.as_str())).collect_vec(),
+                files: g
+                    .files
+                    .iter()
+                    .map(|s| {
+                        Path::from_escaped_string(s.as_str()).map_err(|e| {
+                            io::Error::new(
+                                io::ErrorKind::InvalidData,
+                                format!("Invalid path {}: {}", s, e),
+                            )
+                        })
+                    })
+                    .try_collect()?,
             })
         });
         let iter = fallible_iterator::convert(iter);
@@ -643,6 +660,7 @@ pub fn open_report(r: impl Read + Send + 'static) -> io::Result<Box<dyn ReportRe
 #[cfg(test)]
 mod test {
     use std::env::current_dir;
+    use std::ffi::OsString;
 
     use tempfile::NamedTempFile;
 
@@ -653,8 +671,8 @@ mod test {
 
     fn dummy_report_header() -> ReportHeader {
         ReportHeader {
-            command: vec!["fclones".to_owned(), "find".to_owned(), ".".to_owned()],
-            base_dir: current_dir().unwrap().to_string_lossy().to_string(),
+            command: vec![Arg::from("fclones"), Arg::from("find"), Arg::from(".")],
+            base_dir: Path::from(current_dir().unwrap()),
             version: env!("CARGO_PKG_VERSION").to_owned(),
             timestamp: DateTime::parse_from_str("2021-08-27 12:11:23.456 +0000", TIMESTAMP_FMT)
                 .unwrap(),
@@ -689,6 +707,19 @@ mod test {
         assert_eq!(header2.stats, header1.stats);
     }
 
+    fn roundtrip_groups_text(header: &ReportHeader, groups: Vec<FileGroup<Path>>) {
+        let output = NamedTempFile::new().unwrap();
+        let input = output.reopen().unwrap();
+
+        let mut writer = ReportWriter::new(output, false);
+        writer.write_as_text(&header, groups.iter()).unwrap();
+        let mut reader = Box::new(TextReportReader::new(BufReader::new(input)));
+        reader.read_header().unwrap();
+
+        let groups2: Vec<_> = reader.read_groups().unwrap().collect().unwrap();
+        assert_eq!(groups, groups2);
+    }
+
     #[test]
     fn test_text_report_reader_reads_files() {
         let header = dummy_report_header();
@@ -705,16 +736,42 @@ mod test {
             },
         ];
 
-        let output = NamedTempFile::new().unwrap();
-        let input = output.reopen().unwrap();
+        roundtrip_groups_text(&header, groups);
+    }
 
-        let mut writer = ReportWriter::new(output, false);
-        writer.write_as_text(&header, groups.iter()).unwrap();
-        let mut reader = Box::new(TextReportReader::new(BufReader::new(input)));
-        reader.read_header().unwrap();
+    #[test]
+    fn test_text_report_reader_reads_files_with_control_chars_in_names() {
+        let header = dummy_report_header();
+        let groups = vec![
+            FileGroup {
+                file_len: FileLen(100),
+                file_hash: FileHash(0x00112233445566778899aabbccddeeff),
+                files: vec![Path::from("\t\r\n/foo"), Path::from("ąę/ść/żź/óń/")],
+            },
+            FileGroup {
+                file_len: FileLen(40),
+                file_hash: FileHash(0x0000000000000555555555ffffffffff),
+                files: vec![Path::from("c\u{7f}"), Path::from("😀/😋")],
+            },
+        ];
 
-        let groups2: Vec<_> = reader.read_groups().unwrap().collect().unwrap();
-        assert_eq!(groups, groups2);
+        roundtrip_groups_text(&header, groups);
+    }
+
+    #[cfg(unix)]
+    #[test]
+    fn test_text_report_reader_reads_files_with_non_utf8_chars_in_names() {
+        use std::os::unix::ffi::OsStringExt;
+        let header = dummy_report_header();
+        let groups = vec![FileGroup {
+            file_len: FileLen(100),
+            file_hash: FileHash(0x00112233445566778899aabbccddeeff),
+            files: vec![Path::from(OsString::from_vec(vec![
+                0xED, 0xA0, 0xBD, 0xED, 0xB8, 0x8D,
+            ]))],
+        }];
+
+        roundtrip_groups_text(&header, groups);
     }
 
     #[test]
@@ -783,6 +840,19 @@ mod test {
         assert_eq!(header2.stats, header1.stats);
     }
 
+    fn roundtrip_groups_json(header: &ReportHeader, groups: Vec<FileGroup<Path>>) {
+        let output = NamedTempFile::new().unwrap();
+        let input = output.reopen().unwrap();
+
+        let mut writer = ReportWriter::new(output, false);
+        writer.write_as_json(&header, groups.iter()).unwrap();
+        let mut reader = Box::new(JsonReportReader::new(input).unwrap());
+        reader.read_header().unwrap();
+
+        let groups2: Vec<_> = reader.read_groups().unwrap().collect().unwrap();
+        assert_eq!(groups, groups2);
+    }
+
     #[test]
     fn test_json_report_reader_reads_files() {
         let header = dummy_report_header();
@@ -799,19 +869,45 @@ mod test {
             },
         ];
 
-        let output = NamedTempFile::new().unwrap();
-        let input = output.reopen().unwrap();
+        roundtrip_groups_json(&header, groups);
+    }
 
-        let mut writer = ReportWriter::new(output, false);
-        writer.write_as_json(&header, groups.iter()).unwrap();
-        let mut reader = Box::new(JsonReportReader::new(input).unwrap());
-        reader.read_header().unwrap();
+    #[test]
+    fn test_json_report_reader_reads_files_with_control_chars_in_names() {
+        let header = dummy_report_header();
+        let groups = vec![
+            FileGroup {
+                file_len: FileLen(100),
+                file_hash: FileHash(0x00112233445566778899aabbccddeeff),
+                files: vec![Path::from("\t\r\n/foo"), Path::from("ąę/ść/żź/óń/")],
+            },
+            FileGroup {
+                file_len: FileLen(40),
+                file_hash: FileHash(0x0000000000000555555555ffffffffff),
+                files: vec![Path::from("c\u{7f}"), Path::from("😀/😋")],
+            },
+        ];
 
-        let groups2: Vec<_> = reader.read_groups().unwrap().collect().unwrap();
-        assert_eq!(groups, groups2);
+        roundtrip_groups_json(&header, groups);
+    }
+
+    #[cfg(unix)]
+    #[test]
+    fn test_json_report_reader_reads_files_with_non_utf8_chars_in_names() {
+        use std::os::unix::ffi::OsStringExt;
+        let header = dummy_report_header();
+        let groups = vec![FileGroup {
+            file_len: FileLen(100),
+            file_hash: FileHash(0x00112233445566778899aabbccddeeff),
+            files: vec![Path::from(OsString::from_vec(vec![
+                0xED, 0xA0, 0xBD, 0xED, 0xB8, 0x8D,
+            ]))],
+        }];
+
+        roundtrip_groups_json(&header, groups);
     }
 
-    fn write_read_header(header: &ReportHeader, format: OutputFormat) -> ReportHeader {
+    fn roundtrip_header(header: &ReportHeader, format: OutputFormat) -> ReportHeader {
         let groups: Vec<FileGroup<Path>> = vec![];
         let output = NamedTempFile::new().unwrap();
         let input = output.reopen().unwrap();
@@ -824,8 +920,8 @@ mod test {
     #[test]
     fn test_format_autodetection() {
         let header = dummy_report_header();
-        let reread_header_1 = write_read_header(&header, OutputFormat::Default);
-        let reread_header_2 = write_read_header(&header, OutputFormat::Json);
+        let reread_header_1 = roundtrip_header(&header, OutputFormat::Default);
+        let reread_header_2 = roundtrip_header(&header, OutputFormat::Json);
         assert_eq!(header, reread_header_1);
         assert_eq!(header, reread_header_2);
     }
diff --git a/src/transform.rs b/src/transform.rs
index 117bad6..9d7218c 100644
--- a/src/transform.rs
+++ b/src/transform.rs
@@ -214,7 +214,7 @@ impl Transform {
             Ok(result) if !result.status.success() => {
                 log.err(format!(
                     "Failed to transform {}: {} returned non-zero status code: {}{}",
-                    input,
+                    input.display(),
                     self.program,
                     result.status.code().unwrap(),
                     Self::format_output_stream(result.stderr.as_str(), "STDERR")
diff --git a/src/walk.rs b/src/walk.rs
index f91a326..41d012f 100644
--- a/src/walk.rs
+++ b/src/walk.rs
@@ -116,7 +116,7 @@ impl<'a> Walk<'a> {
                 match fs::metadata(&p.to_path_buf()) {
                     Ok(metadata) if metadata.is_dir() && self.depth == 0 => self.log_warn(format!(
                         "Skipping directory {} because recursive scan is disabled.",
-                        p
+                        p.display()
                     )),
                     _ => scope.spawn(|scope| self.visit_path(p, scope, 0, &state)),
                 }


Test patch: