Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,10 @@ remains below is the design-forked / larger work.
outstanding except, if a concrete case ever appears, extending impossible-task
detection beyond birth time (only `-Btime`/`-Bmin`/X=B `-newerXY` flag it today;
a Y=B reference with no btime stays a silent no-match by design).
- **`--exact` + `--path-encoding`** (#45). Decided (2026-06-28): the **default is
- **`--exact` + `--path-encoding`** (#45). `--path-encoding=raw|escape` has
shipped: the plain renderer C-escapes backslash + control bytes under `escape`
(kNul stays raw, kJsonl always JSON-escapes). `--exact` is still to do. Decided
(2026-06-28): the **default is
the filesystem-native, naturally-expected behavior** - matching follows the
volume's own case-sensitivity (case-insensitive on a folding FS like APFS / HFS+ /
NTFS, case-sensitive on ext4 and friends), so most users get what they expect on
Expand Down
6 changes: 6 additions & 0 deletions xff/cli/globals.cc
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ constexpr std::array kGlobals = std::to_array<GlobalFlag>({
.group = "Output",
.summary = "record format (plain default; nul = -print0; jsonl = JSON lines)",
},
{
.name = "--path-encoding",
.display = "--path-encoding=raw|escape",
.group = "Output",
.summary = "plain-output path byte encoding: raw (verbatim, default) or escape (C-escape controls)",
},
{
.name = "--template",
.display = "--template=TEMPLATE",
Expand Down
1 change: 1 addition & 0 deletions xff/cli/main.cc
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ Options (whole-run, before the paths):
--block-size=SIZE bytes per -size block for a bare `-size N` / `-size Nb` (default 512; e.g. 4k)
Output:
--format=plain|nul|jsonl record format (plain default; nul = -print0; jsonl = JSON lines)
--path-encoding=raw|escape plain-output path bytes: raw (verbatim) or escape (C-escape controls)
--template=TEMPLATE render each match through a field template ({path}, {name}, ...)
--implicit-print=yes|no force the default -print on or off
--summary[=overall|type|ext] print a count + size table instead of each match
Expand Down
18 changes: 17 additions & 1 deletion xff/engine/run.cc
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,21 @@ render::Format ResolveFormat(const std::vector<std::string>& globals) {
return format;
}

// --path-encoding=raw|escape: how the plain renderer emits path bytes (see
// render::PathEncoding). Mirrors ResolveFormat -- last occurrence wins, the
// find-compatible kRaw default; applies only to the default/plain output.
render::PathEncoding ResolvePathEncoding(const std::vector<std::string>& globals) {
render::PathEncoding encoding = render::PathEncoding::kRaw;
for (const std::string& global : globals) {
if (global == "--path-encoding=raw") {
encoding = render::PathEncoding::kRaw;
} else if (global == "--path-encoding=escape") {
encoding = render::PathEncoding::kEscape;
}
}
return encoding;
}

// --template=TMPL renders each match through the field vocabulary (xff-native),
// overriding --format for the implicit print. Last occurrence wins; nullopt when
// absent.
Expand Down Expand Up @@ -582,6 +597,7 @@ int RunFind(
options.sort = ResolveSort(command.globals, style);
options.workers = ResolveJobs(command.globals, style);
const render::Format format = ResolveFormat(command.globals);
const render::PathEncoding path_encoding = ResolvePathEncoding(command.globals);
const std::optional<std::string> tmpl = ResolveTemplate(command.globals);
// A -capture whose {capture.NAME} is never referenced ran a subprocess for
// nothing (use -exec for pure side effects); flag it before traversing.
Expand Down Expand Up @@ -736,7 +752,7 @@ int RunFind(
.outputs = &outputs})
+ "\n");
} else {
emit(render::Renderer(format).Record(visit.path));
emit(render::Renderer(format, path_encoding).Record(visit.path));
}
}
if (!control.unsupported.empty() && !unsupported_reported) {
Expand Down
35 changes: 34 additions & 1 deletion xff/render/render.cc
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,31 @@ void AppendJsonEscaped(std::string_view path, std::string* out) {
}
}

// Appends `path` to `out`, C-escaping the backslash and control characters so a
// newline or control byte in a filename cannot corrupt line-oriented output: `\\`,
// `\n`, `\t`, `\r`, and any other byte < 0x20 or 0x7F (DEL) as `\xNN`. Printable
// ASCII and high (UTF-8) bytes pass through verbatim.
void AppendCEscaped(std::string_view path, std::string* out) {
static constexpr std::string_view kHex = "0123456789ABCDEF";
for (const char ch : path) {
const auto byte = static_cast<unsigned char>(ch);
switch (ch) {
case '\\': out->append("\\\\"); break;
case '\n': out->append("\\n"); break;
case '\t': out->append("\\t"); break;
case '\r': out->append("\\r"); break;
default:
if (byte < 0x20 || byte == 0x7F) {
out->append("\\x");
out->push_back(kHex[byte >> 4]);
out->push_back(kHex[byte & 0x0F]);
} else {
out->push_back(ch);
}
}
}
}

} // namespace

std::string Renderer::Record(std::string_view path) const {
Expand All @@ -63,7 +88,15 @@ std::string Renderer::Record(std::string_view path) const {
record.push_back('\0');
return record;
}
case Format::kPlain: return absl::StrCat(path, "\n");
case Format::kPlain: {
if (encoding_ == PathEncoding::kEscape) {
std::string record;
AppendCEscaped(path, &record);
record.push_back('\n');
return record;
}
return absl::StrCat(path, "\n");
}
}
return absl::StrCat(path, "\n"); // unreachable: every Format returns above
}
Expand Down
20 changes: 14 additions & 6 deletions xff/render/render.h
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,28 @@ namespace xff::render {
// -print/-print0; kJsonl is xff's modern one-object-per-line stream.
enum class Format { kPlain, kNul, kJsonl };

// Formats matched paths into output records. Stateless aside from the format
// selector; cheap to copy.
// How path bytes are emitted (xff `--path-encoding`). kRaw writes the bytes
// verbatim (find-compatible default). kEscape C-escapes the backslash and control
// characters (`\n`, `\t`, `\r`, else `\xNN`) so a newline or control byte in a
// filename cannot corrupt the line-oriented kPlain stream. It applies only to
// kPlain: kNul stays raw by design (the NUL is the separator) and kJsonl always
// JSON-escapes regardless.
enum class PathEncoding { kRaw, kEscape };

// Formats matched paths into output records. Stateless aside from the format +
// encoding selectors; cheap to copy.
class Renderer {
public:
explicit Renderer(Format format) : format_(format) {}
explicit Renderer(Format format, PathEncoding encoding = PathEncoding::kRaw) : format_(format), encoding_(encoding) {}

// Returns the output record for `path`, terminator included:
// kPlain -> "path\n", kNul -> "path\0", kJsonl -> {"path":"<escaped>"}\n.
// For kJsonl the path is JSON-string-escaped (quote, backslash, control
// characters); non-UTF-8 byte handling is refined later with --path-encoding.
// kPlain -> "path\n" (path C-escaped when encoding is kEscape),
// kNul -> "path\0" (always raw), kJsonl -> {"path":"<JSON-escaped>"}\n.
std::string Record(std::string_view path) const;

private:
Format format_;
PathEncoding encoding_;
};

} // namespace xff::render
Expand Down
22 changes: 22 additions & 0 deletions xff/render/render_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,27 @@ TEST_F(RenderTest, JsonlEscapesQuotesBackslashAndControls) {
EXPECT_THAT(Renderer(Format::kJsonl).Record(std::string("x\x01y", 3)), "{\"path\":\"x\\u0001y\"}\n");
}

TEST_F(RenderTest, PlainRawIsTheDefaultEncoding) {
// kPlain defaults to verbatim bytes (find-compatible): a newline in the name passes
// through, splitting the record.
EXPECT_THAT(Renderer(Format::kPlain).Record("a\nb"), "a\nb\n");
}

TEST_F(RenderTest, PlainEscapeCEscapesBackslashAndControls) {
// --path-encoding=escape: backslash + the common control chars become C escapes.
EXPECT_THAT(Renderer(Format::kPlain, PathEncoding::kEscape).Record("a\nb\tc\\d"), "a\\nb\\tc\\\\d\n");
// Other control / DEL bytes use \xNN (upper-case hex); printable + high UTF-8 bytes
// pass through verbatim.
EXPECT_THAT(Renderer(Format::kPlain, PathEncoding::kEscape).Record(std::string("x\x01y\x7f", 4)), "x\\x01y\\x7F\n");
EXPECT_THAT(Renderer(Format::kPlain, PathEncoding::kEscape).Record("caf\xc3\xa9"), "caf\xc3\xa9\n");
}

TEST_F(RenderTest, EscapeAppliesOnlyToPlain) {
// kNul stays raw (the NUL is the separator); kJsonl always JSON-escapes, both
// regardless of the path encoding.
EXPECT_THAT(Renderer(Format::kNul, PathEncoding::kEscape).Record("a\nb"), std::string("a\nb\0", 4));
EXPECT_THAT(Renderer(Format::kJsonl, PathEncoding::kEscape).Record("a\nb"), "{\"path\":\"a\\nb\"}\n");
}

} // namespace
} // namespace xff::render
Loading