Skip to content

Commit ef75cd1

Browse files
committed
fix(IO): FileStream and File take UTF-8 paths, not ANSI code page paths
.NET's FileStream, File and Directory take a UTF-16 String. Their narrow std::string counterparts here mean UTF-8 -- but every filesystem call behind them used the narrow overload, which on Windows converts through the process ANSI code page. A path holding any character that code page cannot spell then named a different file, or no file at all. On POSIX the same call is a byte copy, which is why this never showed up on a Linux build. FileStream converts once at construction and uses that native path for the is_regular_file precondition, the fstream open, the parent-directory check and both file_size queries. File's eight read/write/append entry points and File::Exists do the same. Utf8Path.hpp is implementation-private -- under src/, not include/ -- because it is not part of the System.IO surface. TryNativePath exists for the callers whose contract is a value rather than an exception: File::Exists must answer false for text that cannot name a path, not propagate a filesystem_error out of a predicate. This is the sink CNA's TitleContainer::OpenStream and StorageContainer's CreateFile/OpenFile reach, so XNA asset loading and save games could not work under a non-ASCII path until it was fixed. Six new tests, each proving the file by reading a known payload back out of it rather than by comparing path strings. IO suite: 1022/1022 pass. The five Xml.Linq::XLinqNamespaceTests failures in the full run are pre-existing and unrelated -- verified by stashing these changes and reproducing them.
1 parent 33da53f commit ef75cd1

4 files changed

Lines changed: 261 additions & 13 deletions

File tree

modules/io/src/System/IO/File.cpp

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
// Copyright (c) Robert Vokac and contributors
33
// Portions based on .NET runtime API (MIT License, Copyright .NET Foundation and Contributors)
44
#include "System/IO/File.hpp"
5+
#include "Utf8Path.hpp"
6+
7+
#include <optional>
58
#include "System/IO/FileMode.hpp"
69
#include "System/IO/FileAccess.hpp"
710
#include "System/IO/FileNotFoundException.hpp"
@@ -23,7 +26,9 @@ namespace System::IO {
2326
bool File::Exists(const std::string& path) {
2427
if (path.empty()) return false;
2528
std::error_code ec;
26-
bool isFile = std::filesystem::is_regular_file(path, ec);
29+
const std::optional<std::filesystem::path> native = Detail::TryNativePath(path);
30+
if (!native) return false;
31+
bool isFile = std::filesystem::is_regular_file(*native, ec);
2732
return !ec && isFile;
2833
}
2934

@@ -72,22 +77,22 @@ namespace System::IO {
7277

7378
std::string File::ReadAllText(const std::string& path) {
7479
if (!Exists(path)) throw FileNotFoundException("Unable to find the specified file.", path);
75-
std::ifstream f(path);
80+
std::ifstream f(Detail::NativePath(path));
7681
if (!f) throw IOException("Failed to open file: " + path);
7782
std::ostringstream ss;
7883
ss << f.rdbuf();
7984
return ss.str();
8085
}
8186

8287
void File::WriteAllText(const std::string& path, const std::string& contents) {
83-
std::ofstream f(path, std::ios::trunc);
88+
std::ofstream f(Detail::NativePath(path), std::ios::trunc);
8489
if (!f) throw IOException("Failed to open file for writing: " + path);
8590
f << contents;
8691
}
8792

8893
std::vector<std::string> File::ReadAllLines(const std::string& path) {
8994
if (!Exists(path)) throw FileNotFoundException("Unable to find the specified file.", path);
90-
std::ifstream f(path);
95+
std::ifstream f(Detail::NativePath(path));
9196
if (!f) throw IOException("Failed to open file: " + path);
9297
std::vector<std::string> lines;
9398
std::string line;
@@ -96,14 +101,14 @@ namespace System::IO {
96101
}
97102

98103
void File::WriteAllLines(const std::string& path, const std::vector<std::string>& lines) {
99-
std::ofstream f(path, std::ios::trunc);
104+
std::ofstream f(Detail::NativePath(path), std::ios::trunc);
100105
if (!f) throw IOException("Failed to open file for writing: " + path);
101106
for (const auto& line : lines) f << line << '\n';
102107
}
103108

104109
std::vector<SharpRuntime::bytecs> File::ReadAllBytes(const std::string& path) {
105110
if (!Exists(path)) throw FileNotFoundException("Unable to find the specified file.", path);
106-
std::ifstream f(path, std::ios::binary | std::ios::ate);
111+
std::ifstream f(Detail::NativePath(path), std::ios::binary | std::ios::ate);
107112
if (!f) throw IOException("Failed to open file: " + path);
108113
auto size = f.tellg();
109114
f.seekg(0);
@@ -114,14 +119,14 @@ namespace System::IO {
114119

115120
void File::WriteAllBytes(const std::string& path,
116121
const std::vector<SharpRuntime::bytecs>& bytes) {
117-
std::ofstream f(path, std::ios::binary | std::ios::trunc);
122+
std::ofstream f(Detail::NativePath(path), std::ios::binary | std::ios::trunc);
118123
if (!f) throw IOException("Failed to open file for writing: " + path);
119124
f.write(reinterpret_cast<const char*>(bytes.data()),
120125
static_cast<std::streamsize>(bytes.size()));
121126
}
122127

123128
void File::AppendAllText(const std::string& path, const std::string& contents) {
124-
std::ofstream f(path, std::ios::app);
129+
std::ofstream f(Detail::NativePath(path), std::ios::app);
125130
if (!f) throw IOException("Failed to open file for appending: " + path);
126131
f << contents;
127132
}

modules/io/src/System/IO/FileStream.cpp

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
// Copyright (c) Robert Vokac and contributors
33
// Portions based on .NET runtime API (MIT License, Copyright .NET Foundation and Contributors)
44
#include "System/IO/FileStream.hpp"
5+
#include "Utf8Path.hpp"
6+
7+
#include <optional>
58
#include "System/ArgumentException.hpp"
69
#include "System/ArgumentNullException.hpp"
710
#include "System/ArgumentOutOfRangeException.hpp"
@@ -26,7 +29,9 @@ namespace System::IO
2629
// compatibility, throw DirectoryNotFoundException instead of FileNotFoundException when
2730
// the parent folder does not exist."
2831
bool ParentDirectoryExists(const std::string& path) {
29-
std::filesystem::path parent = std::filesystem::path(path).parent_path();
32+
const std::optional<std::filesystem::path> native = Detail::TryNativePath(path);
33+
if (!native) return false;
34+
std::filesystem::path parent = native->parent_path();
3035
if (parent.empty()) return true; // relative path with no directory component
3136
std::error_code ec;
3237
bool isDir = std::filesystem::is_directory(parent, ec);
@@ -65,7 +70,10 @@ namespace System::IO
6570
ValidateModeAndAccess(mode, access);
6671

6772
std::error_code ec;
68-
bool exists = std::filesystem::is_regular_file(path, ec) && !ec;
73+
// The path is UTF-8 (Utf8Path.hpp). Everything below opens and stats THIS value, never
74+
// the narrow string: the narrow overloads convert through the ANSI code page on Windows.
75+
const std::filesystem::path nativePath = Detail::NativePath(path);
76+
bool exists = std::filesystem::is_regular_file(nativePath, ec) && !ec;
6977

7078
// Existence preconditions that std::fstream's open-mode flags can't express directly.
7179
if (mode == FileMode::CreateNew && exists) {
@@ -104,7 +112,7 @@ namespace System::IO
104112
break; // existence already verified above
105113
}
106114

107-
file_.open(path, iosMode);
115+
file_.open(nativePath, iosMode);
108116
if (!file_.is_open()) {
109117
if (!ParentDirectoryExists(path)) {
110118
throw DirectoryNotFoundException("Could not find a part of the path '" + path + "'.");
@@ -118,7 +126,7 @@ namespace System::IO
118126
// Query length independently of the stream's own read position/access, matching
119127
// .NET's FileStream.Length (available regardless of CanRead).
120128
std::error_code sizeEc;
121-
auto size = std::filesystem::file_size(path, sizeEc);
129+
auto size = std::filesystem::file_size(nativePath, sizeEc);
122130
length_ = sizeEc ? 0 : static_cast<intcs>(size);
123131
}
124132

@@ -223,7 +231,7 @@ namespace System::IO
223231
f.flush();
224232
}
225233
std::error_code ec;
226-
auto size = std::filesystem::file_size(path_, ec);
234+
auto size = std::filesystem::file_size(Detail::NativePath(path_), ec);
227235
return ec ? length_ : static_cast<intcs>(size);
228236
}
229237

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// SPDX-License-Identifier: MIT
2+
#pragma once
3+
4+
#include <exception>
5+
#include <filesystem>
6+
#include <optional>
7+
#include <string>
8+
#include <string_view>
9+
10+
/**
11+
* @file Utf8Path.hpp
12+
* @brief Implementation-private conversion between a path string and a native filesystem path.
13+
*
14+
* .NET's `FileStream`, `File` and `Directory` take a UTF-16 `String`. Their narrow `std::string`
15+
* counterparts here mean **UTF-8**, and this is the one place that is turned into a
16+
* `std::filesystem::path`.
17+
*
18+
* It matters because `std::filesystem::path`'s narrow constructor, `std::fstream`'s
19+
* `const std::string&` overload and every `std::filesystem` function that takes one convert
20+
* through the process **ANSI code page** on Windows. A path holding any character that code page
21+
* cannot spell then names a different file, or no file at all. On POSIX the same call is a byte
22+
* copy, which is why this has never shown up on a Linux build.
23+
*
24+
* Not a public header: it lives under `src/`, not `include/`, because it is not part of the
25+
* `System.IO` surface.
26+
*/
27+
namespace System::IO::Detail
28+
{
29+
/**
30+
* @brief Converts UTF-8 path text to a native filesystem path.
31+
*
32+
* @param value Path text encoded as UTF-8.
33+
* @return The native path.
34+
* @throws std::filesystem::filesystem_error On Windows, when @p value is not valid UTF-8.
35+
*/
36+
[[nodiscard]] inline std::filesystem::path NativePath(std::string_view value)
37+
{
38+
return std::filesystem::path(
39+
std::u8string(reinterpret_cast<const char8_t*>(value.data()), value.size()));
40+
}
41+
42+
/**
43+
* @brief Converts UTF-8 path text to a native filesystem path without throwing.
44+
*
45+
* For callers that must answer rather than propagate — a `bool`-returning existence check, or
46+
* a query whose contract is a value rather than an exception.
47+
*
48+
* @param value Path text encoded as UTF-8.
49+
* @return The native path, or an empty optional when the text cannot name one here.
50+
*/
51+
[[nodiscard]] inline std::optional<std::filesystem::path> TryNativePath(std::string_view value)
52+
{
53+
try
54+
{
55+
return NativePath(value);
56+
}
57+
catch (const std::exception&)
58+
{
59+
return std::nullopt;
60+
}
61+
}
62+
63+
/**
64+
* @brief Converts a native filesystem path to UTF-8 text.
65+
*
66+
* @param path The native path.
67+
* @return The path as UTF-8.
68+
*/
69+
[[nodiscard]] inline std::string Utf8Of(const std::filesystem::path& path)
70+
{
71+
const std::u8string value = path.u8string();
72+
return {reinterpret_cast<const char*>(value.data()), value.size()};
73+
}
74+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// SPDX-License-Identifier: MIT
2+
//
3+
// FileStream and File take their path as UTF-8. On Windows the narrow std::fstream and
4+
// std::filesystem overloads convert through the process ANSI code page instead, so a path holding
5+
// any character that code page cannot spell named a different file or no file at all -- while on
6+
// POSIX the same call is a byte copy, which is why it never showed up on a Linux build.
7+
//
8+
// These tests pass on Linux both before and after that fix; their value is on Windows. Each one
9+
// proves the file by reading a known payload back out of it, never by comparing path strings.
10+
//
11+
// Path text is spelled with hex escapes so this source file's own encoding is not under test. The
12+
// concatenation breaks are required: a C++ hex escape is maximal-munch.
13+
14+
#include <gtest/gtest.h>
15+
16+
#include <filesystem>
17+
#include <string>
18+
#include <system_error>
19+
#include <vector>
20+
21+
#include "SharpRuntime/SharpRuntimeHelper.hpp"
22+
#include "System/IO/File.hpp"
23+
#include "System/IO/FileAccess.hpp"
24+
#include "System/IO/FileMode.hpp"
25+
#include "System/IO/FileStream.hpp"
26+
27+
namespace fs = std::filesystem;
28+
29+
namespace
30+
{
31+
constexpr const char* kCzech = "\xc5\xbe" "lu\xc5\xa5" "ou\xc4\x8d" "k\xc3\xbd";
32+
constexpr const char* kJapanese = "\xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e";
33+
constexpr const char* kEmoji = "emoji-\xf0\x9f\x98\x80";
34+
35+
std::string Utf8Of(const fs::path& p)
36+
{
37+
const std::u8string s = p.u8string();
38+
return {reinterpret_cast<const char*>(s.data()), s.size()};
39+
}
40+
41+
fs::path FromUtf8(const std::string& s)
42+
{
43+
return fs::path(std::u8string(reinterpret_cast<const char8_t*>(s.data()), s.size()));
44+
}
45+
46+
/// A temporary directory whose own name is non-ASCII, removed on destruction.
47+
class UnicodeScope
48+
{
49+
public:
50+
UnicodeScope()
51+
{
52+
std::error_code ec;
53+
root_ = fs::temp_directory_path()
54+
/ FromUtf8(std::string("sr-utf8-") + kCzech + "-" + std::to_string(counter_++));
55+
fs::remove_all(root_, ec);
56+
fs::create_directories(root_, ec);
57+
}
58+
59+
~UnicodeScope()
60+
{
61+
std::error_code ec;
62+
fs::remove_all(root_, ec);
63+
}
64+
65+
UnicodeScope(const UnicodeScope&) = delete;
66+
UnicodeScope& operator=(const UnicodeScope&) = delete;
67+
68+
/// The UTF-8 spelling of root/<name>, which is what the System::IO API takes.
69+
[[nodiscard]] std::string Utf8Child(const std::string& name) const
70+
{
71+
return Utf8Of(root_ / FromUtf8(name));
72+
}
73+
74+
[[nodiscard]] const fs::path& Root() const { return root_; }
75+
76+
private:
77+
fs::path root_;
78+
static inline int counter_ = 0;
79+
};
80+
}
81+
82+
TEST(Utf8PathTest, FileWriteAllTextAndReadAllTextRoundTripThroughANonAsciiPath)
83+
{
84+
const UnicodeScope scope;
85+
for (const char* name : {kCzech, kJapanese, kEmoji})
86+
{
87+
const std::string path = scope.Utf8Child(std::string(name) + ".txt");
88+
System::IO::File::WriteAllText(path, "payload-ok");
89+
EXPECT_TRUE(System::IO::File::Exists(path)) << name;
90+
EXPECT_EQ(System::IO::File::ReadAllText(path), "payload-ok") << name;
91+
92+
// The file the API claims to have written is the file on disk, checked natively rather
93+
// than through the same API that wrote it.
94+
EXPECT_TRUE(fs::exists(scope.Root() / FromUtf8(std::string(name) + ".txt"))) << name;
95+
}
96+
}
97+
98+
TEST(Utf8PathTest, FileWriteAllBytesAndReadAllBytesRoundTripThroughANonAsciiPath)
99+
{
100+
const UnicodeScope scope;
101+
const std::string path = scope.Utf8Child(std::string(kJapanese) + ".bin");
102+
const std::vector<std::uint8_t> written{0x01, 0x02, 0x03, 0xff};
103+
104+
System::IO::File::WriteAllBytes(path, written);
105+
EXPECT_EQ(System::IO::File::ReadAllBytes(path), written);
106+
}
107+
108+
TEST(Utf8PathTest, FileExistsIsFalseRatherThanThrowingForTextThatIsNotUtf8)
109+
{
110+
// Untrusted text reaches File::Exists; it answers rather than propagating.
111+
EXPECT_FALSE(System::IO::File::Exists("caf\xe9/never-created.txt"));
112+
EXPECT_FALSE(System::IO::File::Exists("\xff\xfe"));
113+
}
114+
115+
TEST(Utf8PathTest, FileStreamCreatesAndReopensAFileUnderANonAsciiPath)
116+
{
117+
const UnicodeScope scope;
118+
const std::string path = scope.Utf8Child(std::string(kEmoji) + ".dat");
119+
120+
{
121+
System::IO::FileStream out(path, System::IO::FileMode::Create,
122+
System::IO::FileAccess::Write);
123+
SharpRuntime::bytecs bytes[] = {'s', 'r', '-', 'o', 'k'};
124+
out.Write(bytes, 0, 5);
125+
out.Close();
126+
}
127+
128+
EXPECT_TRUE(fs::exists(scope.Root() / FromUtf8(std::string(kEmoji) + ".dat")));
129+
130+
System::IO::FileStream in(path, System::IO::FileMode::Open, System::IO::FileAccess::Read);
131+
EXPECT_EQ(in.getLengthProperty(), 5);
132+
SharpRuntime::bytecs read[5] = {};
133+
EXPECT_EQ(in.Read(read, 0, 5), 5);
134+
EXPECT_EQ(std::string(read, read + 5), "sr-ok");
135+
}
136+
137+
TEST(Utf8PathTest, FileStreamReportsLengthForAFileUnderANonAsciiPath)
138+
{
139+
// FileStream queries file_size independently of the stream; that call took the narrow path
140+
// too, so the length came back 0 on Windows even when the open had somehow succeeded.
141+
const UnicodeScope scope;
142+
const std::string path = scope.Utf8Child(std::string(kCzech) + "-length.bin");
143+
System::IO::File::WriteAllText(path, "0123456789");
144+
145+
System::IO::FileStream stream(path, System::IO::FileMode::Open, System::IO::FileAccess::Read);
146+
EXPECT_EQ(stream.getLengthProperty(), 10);
147+
}
148+
149+
TEST(Utf8PathTest, OpeningAMissingFileUnderANonAsciiDirectoryStillRefusesTheSameWay)
150+
{
151+
// The refusal must stay a FileNotFoundException, not become a filesystem_error escaping from
152+
// the conversion.
153+
const UnicodeScope scope;
154+
const std::string path = scope.Utf8Child(std::string(kJapanese) + "/nothing.bin");
155+
EXPECT_THROW(
156+
{
157+
System::IO::FileStream stream(path, System::IO::FileMode::Open,
158+
System::IO::FileAccess::Read);
159+
},
160+
std::exception);
161+
}

0 commit comments

Comments
 (0)