dsh-ops 0.0.0-stage → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +202 -0
- package/LICENSE +30 -0
- package/NOTICE +106 -0
- package/PROVENANCE.md +435 -0
- package/README.en.md +126 -0
- package/README.md +115 -2
- package/README.zh.md +116 -0
- package/bin/dsh-ops.mjs +1216 -0
- package/cordis.patch.yml +160 -0
- package/docs/manual-validation.md +53 -0
- package/docs/release-0.2.1.md +72 -0
- package/docs/schema-baseline.json +64 -0
- package/docs/schema-current.json +84 -0
- package/docs/schema-measurement.md +17 -0
- package/dsh-plugin.json +88 -0
- package/icon.svg +12 -0
- package/lib/binary.js +409 -0
- package/lib/config.js +198 -0
- package/lib/handshake.js +252 -0
- package/lib/index.js +108 -0
- package/lib/jobs.js +42 -0
- package/lib/policy.js +64 -0
- package/lib/presentation.js +63 -0
- package/lib/profile-install.js +61 -0
- package/lib/rust.js +194 -0
- package/lib/session-shells.js +78 -0
- package/lib/shells.js +998 -0
- package/lib/tools.js +657 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +114 -4
- package/vendor/fastctx/Cargo.lock +3210 -0
- package/vendor/fastctx/Cargo.toml +94 -0
- package/vendor/fastctx/FORK.md +119 -0
- package/vendor/fastctx/LICENSE-APACHE +201 -0
- package/vendor/fastctx/NOTICE +40 -0
- package/vendor/fastctx/README.md +439 -0
- package/vendor/fastctx/THIRD_PARTY_LICENSES.md +17 -0
- package/vendor/fastctx/THIRD_PARTY_LICENSES_RUST.md +7914 -0
- package/vendor/fastctx/UPSTREAM.md +49 -0
- package/vendor/fastctx/build.rs +413 -0
- package/vendor/fastctx/src/background_status.rs +403 -0
- package/vendor/fastctx/src/binary.rs +75 -0
- package/vendor/fastctx/src/bounded_sort.rs +500 -0
- package/vendor/fastctx/src/budget.rs +781 -0
- package/vendor/fastctx/src/cli/mod.rs +110 -0
- package/vendor/fastctx/src/context_guard.rs +289 -0
- package/vendor/fastctx/src/control/mod.rs +6 -0
- package/vendor/fastctx/src/control/paths.rs +49 -0
- package/vendor/fastctx/src/control/settings.rs +753 -0
- package/vendor/fastctx/src/control/transaction.rs +531 -0
- package/vendor/fastctx/src/edit/document.rs +535 -0
- package/vendor/fastctx/src/edit/locks.rs +371 -0
- package/vendor/fastctx/src/edit/mod.rs +213 -0
- package/vendor/fastctx/src/edit/private_storage/unix.rs +315 -0
- package/vendor/fastctx/src/edit/private_storage/windows.rs +793 -0
- package/vendor/fastctx/src/edit/private_storage.rs +234 -0
- package/vendor/fastctx/src/edit/replace.rs +1030 -0
- package/vendor/fastctx/src/edit_server.rs +53 -0
- package/vendor/fastctx/src/encoding/reference_v011.rs +587 -0
- package/vendor/fastctx/src/encoding/snapshot_pipeline.rs +1678 -0
- package/vendor/fastctx/src/encoding.rs +1118 -0
- package/vendor/fastctx/src/file_executor.rs +1151 -0
- package/vendor/fastctx/src/file_snapshot.rs +1491 -0
- package/vendor/fastctx/src/glob_filter.rs +98 -0
- package/vendor/fastctx/src/glob_tool.rs +653 -0
- package/vendor/fastctx/src/grep_sink.rs +1162 -0
- package/vendor/fastctx/src/grep_tool.rs +2449 -0
- package/vendor/fastctx/src/lib.rs +45 -0
- package/vendor/fastctx/src/main.rs +15 -0
- package/vendor/fastctx/src/model.rs +51 -0
- package/vendor/fastctx/src/model_guidance.rs +62 -0
- package/vendor/fastctx/src/operation.rs +356 -0
- package/vendor/fastctx/src/ordered_window.rs +1235 -0
- package/vendor/fastctx/src/os_environment.rs +414 -0
- package/vendor/fastctx/src/path_codec.rs +850 -0
- package/vendor/fastctx/src/paths.rs +244 -0
- package/vendor/fastctx/src/process_identity.rs +763 -0
- package/vendor/fastctx/src/process_policy.rs +74 -0
- package/vendor/fastctx/src/read_tool/batch.rs +496 -0
- package/vendor/fastctx/src/read_tool/hex_file.rs +141 -0
- package/vendor/fastctx/src/read_tool/image_file.rs +88 -0
- package/vendor/fastctx/src/read_tool/mod.rs +245 -0
- package/vendor/fastctx/src/read_tool/pdf.rs +470 -0
- package/vendor/fastctx/src/read_tool/pdf_disabled.rs +47 -0
- package/vendor/fastctx/src/read_tool/pdf_engine.rs +664 -0
- package/vendor/fastctx/src/read_tool/text_file.rs +351 -0
- package/vendor/fastctx/src/render_plan.rs +468 -0
- package/vendor/fastctx/src/runtime/activity.rs +159 -0
- package/vendor/fastctx/src/runtime/hosts.rs +99 -0
- package/vendor/fastctx/src/runtime/journal.rs +556 -0
- package/vendor/fastctx/src/runtime/local_ipc.rs +186 -0
- package/vendor/fastctx/src/runtime/mod.rs +746 -0
- package/vendor/fastctx/src/runtime/protocol.rs +296 -0
- package/vendor/fastctx/src/runtime/session.rs +536 -0
- package/vendor/fastctx/src/runtime/windows_process.rs +66 -0
- package/vendor/fastctx/src/search_parallelism.rs +106 -0
- package/vendor/fastctx/src/search_text.rs +227 -0
- package/vendor/fastctx/src/server.rs +359 -0
- package/vendor/fastctx/src/server_manifest.rs +468 -0
- package/vendor/fastctx/src/server_support.rs +826 -0
- package/vendor/fastctx/src/session.rs +629 -0
- package/vendor/fastctx/src/shell/apply_patch_hint.rs +41 -0
- package/vendor/fastctx/src/shell/bash.rs +263 -0
- package/vendor/fastctx/src/shell/buffer.rs +108 -0
- package/vendor/fastctx/src/shell/encoding.rs +403 -0
- package/vendor/fastctx/src/shell/foreground.rs +115 -0
- package/vendor/fastctx/src/shell/jobs/admission.rs +91 -0
- package/vendor/fastctx/src/shell/jobs/background.rs +146 -0
- package/vendor/fastctx/src/shell/jobs/host.rs +830 -0
- package/vendor/fastctx/src/shell/jobs/identity.rs +29 -0
- package/vendor/fastctx/src/shell/jobs/mod.rs +1513 -0
- package/vendor/fastctx/src/shell/jobs/model.rs +244 -0
- package/vendor/fastctx/src/shell/jobs/output_log.rs +1148 -0
- package/vendor/fastctx/src/shell/jobs/store.rs +1300 -0
- package/vendor/fastctx/src/shell/mod.rs +345 -0
- package/vendor/fastctx/src/shell/normalize.rs +389 -0
- package/vendor/fastctx/src/shell/output.rs +406 -0
- package/vendor/fastctx/src/shell/process.rs +493 -0
- package/vendor/fastctx/src/shell_server.rs +156 -0
- package/vendor/fastctx/src/skip_report.rs +83 -0
- package/vendor/fastctx/src/stdio_transport.rs +177 -0
- package/vendor/fastctx/src/tool_schema.rs +204 -0
- package/vendor/fastctx/src/traversal.rs +846 -0
- package/vendor/fastctx/third-party/pdfium-7763/LICENSE +9 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/abseil.txt +202 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/agg23.txt +14 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/fast_float.txt +27 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/freetype.txt +169 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/icu.txt +542 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/lcms.txt +27 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.ijg +260 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.md +135 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libopenjpeg.txt +32 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libpng.txt +134 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libtiff.txt +21 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/llvm-libc.txt +278 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/pdfium.txt +230 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/simdutf.txt +18 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/zlib.txt +29 -0
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
//! Lossless search-path identity, display encoding, and root resolution.
|
|
2
|
+
|
|
3
|
+
use crate::paths::canonical_existing;
|
|
4
|
+
use std::ffi::{OsStr, OsString};
|
|
5
|
+
use std::fmt;
|
|
6
|
+
use std::fs::{self, Metadata};
|
|
7
|
+
use std::io;
|
|
8
|
+
use std::path::{Component, Path, PathBuf};
|
|
9
|
+
use std::sync::Arc;
|
|
10
|
+
use std::time::SystemTime;
|
|
11
|
+
|
|
12
|
+
const BYTE_TOKEN_PREFIX: &str = "~fastctx~b";
|
|
13
|
+
const WINDOWS_TOKEN_PREFIX: &str = "~fastctx~w";
|
|
14
|
+
const TOKEN_SUFFIX: char = '~';
|
|
15
|
+
const INVALID_TOKEN_SUFFIX: &str =
|
|
16
|
+
"for this platform. Copy an encoded path exactly as returned by FastCtx.";
|
|
17
|
+
|
|
18
|
+
/// The native, lossless final tie-break key for a path.
|
|
19
|
+
#[cfg(unix)]
|
|
20
|
+
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
|
21
|
+
pub(crate) struct NativePathKey(Vec<u8>);
|
|
22
|
+
|
|
23
|
+
/// The native, lossless final tie-break key for a path.
|
|
24
|
+
#[cfg(windows)]
|
|
25
|
+
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
|
26
|
+
pub(crate) struct NativePathKey(Vec<u16>);
|
|
27
|
+
|
|
28
|
+
/// Metadata observed by traversal before a candidate is opened.
|
|
29
|
+
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
30
|
+
pub(crate) struct FileIdentityHint {
|
|
31
|
+
pub(crate) len: u64,
|
|
32
|
+
pub(crate) modified: Option<SystemTime>,
|
|
33
|
+
#[cfg(unix)]
|
|
34
|
+
pub(crate) device: u64,
|
|
35
|
+
#[cfg(unix)]
|
|
36
|
+
pub(crate) inode: u64,
|
|
37
|
+
#[cfg(unix)]
|
|
38
|
+
pub(crate) modified_seconds: i64,
|
|
39
|
+
#[cfg(unix)]
|
|
40
|
+
pub(crate) modified_nanoseconds: i64,
|
|
41
|
+
#[cfg(unix)]
|
|
42
|
+
pub(crate) changed_seconds: i64,
|
|
43
|
+
#[cfg(unix)]
|
|
44
|
+
pub(crate) changed_nanoseconds: i64,
|
|
45
|
+
#[cfg(windows)]
|
|
46
|
+
pub(crate) creation_time: u64,
|
|
47
|
+
#[cfg(windows)]
|
|
48
|
+
pub(crate) last_write_time: u64,
|
|
49
|
+
#[cfg(windows)]
|
|
50
|
+
pub(crate) attributes: u32,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
impl FileIdentityHint {
|
|
54
|
+
fn from_metadata(metadata: &Metadata) -> Self {
|
|
55
|
+
#[cfg(unix)]
|
|
56
|
+
{
|
|
57
|
+
use std::os::unix::fs::MetadataExt;
|
|
58
|
+
Self {
|
|
59
|
+
len: metadata.len(),
|
|
60
|
+
modified: metadata.modified().ok(),
|
|
61
|
+
device: metadata.dev(),
|
|
62
|
+
inode: metadata.ino(),
|
|
63
|
+
modified_seconds: metadata.mtime(),
|
|
64
|
+
modified_nanoseconds: metadata.mtime_nsec(),
|
|
65
|
+
changed_seconds: metadata.ctime(),
|
|
66
|
+
changed_nanoseconds: metadata.ctime_nsec(),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
#[cfg(windows)]
|
|
70
|
+
{
|
|
71
|
+
use std::os::windows::fs::MetadataExt;
|
|
72
|
+
Self {
|
|
73
|
+
len: metadata.len(),
|
|
74
|
+
modified: metadata.modified().ok(),
|
|
75
|
+
creation_time: metadata.creation_time(),
|
|
76
|
+
last_write_time: metadata.last_write_time(),
|
|
77
|
+
attributes: metadata.file_attributes(),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// One candidate path with separate native identity and canonical model-facing keys.
|
|
84
|
+
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
85
|
+
pub(crate) struct PathRecord {
|
|
86
|
+
pub(crate) native: PathBuf,
|
|
87
|
+
pub(crate) display: Arc<str>,
|
|
88
|
+
pub(crate) relative_match: Arc<str>,
|
|
89
|
+
pub(crate) native_key: NativePathKey,
|
|
90
|
+
pub(crate) modified: Option<SystemTime>,
|
|
91
|
+
pub(crate) traversal_len_hint: Option<u64>,
|
|
92
|
+
pub(crate) traversal_fingerprint: Option<FileIdentityHint>,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
impl PathRecord {
|
|
96
|
+
/// Captures lossless path keys without performing metadata IO.
|
|
97
|
+
pub(crate) fn without_metadata(native: &Path, match_root: &Path) -> Self {
|
|
98
|
+
Self {
|
|
99
|
+
native: native.to_path_buf(),
|
|
100
|
+
display: Arc::from(display_path(native)),
|
|
101
|
+
relative_match: Arc::from(display_path(
|
|
102
|
+
native.strip_prefix(match_root).unwrap_or(native),
|
|
103
|
+
)),
|
|
104
|
+
native_key: native_path_key(native),
|
|
105
|
+
modified: None,
|
|
106
|
+
traversal_len_hint: None,
|
|
107
|
+
traversal_fingerprint: None,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// Captures walker metadata, optionally requiring the modification-time sort key.
|
|
112
|
+
pub(crate) fn from_metadata(
|
|
113
|
+
native: &Path,
|
|
114
|
+
match_root: &Path,
|
|
115
|
+
metadata: &Metadata,
|
|
116
|
+
include_modified: bool,
|
|
117
|
+
) -> io::Result<Self> {
|
|
118
|
+
let mut record = Self::without_metadata(native, match_root);
|
|
119
|
+
record.modified = if include_modified {
|
|
120
|
+
Some(metadata.modified()?)
|
|
121
|
+
} else {
|
|
122
|
+
None
|
|
123
|
+
};
|
|
124
|
+
record.traversal_len_hint = Some(metadata.len());
|
|
125
|
+
record.traversal_fingerprint = Some(FileIdentityHint::from_metadata(metadata));
|
|
126
|
+
Ok(record)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// Whether a resolved grep root is a single file or a directory tree.
|
|
131
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
132
|
+
pub(crate) enum RootKind {
|
|
133
|
+
File,
|
|
134
|
+
Directory,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// A search root whose one metadata result is reused by every downstream decision.
|
|
138
|
+
#[derive(Debug)]
|
|
139
|
+
pub(crate) struct ResolvedRoot {
|
|
140
|
+
pub(crate) native: PathBuf,
|
|
141
|
+
pub(crate) display: Arc<str>,
|
|
142
|
+
pub(crate) metadata: Metadata,
|
|
143
|
+
pub(crate) kind: RootKind,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
impl ResolvedRoot {
|
|
147
|
+
/// Wraps metadata already obtained by a legacy caller without another stat.
|
|
148
|
+
pub(crate) fn from_metadata(native: PathBuf, metadata: Metadata) -> Result<Self, String> {
|
|
149
|
+
let kind = metadata_kind(&native, &metadata)?;
|
|
150
|
+
Ok(Self {
|
|
151
|
+
display: Arc::from(display_path(&native)),
|
|
152
|
+
native,
|
|
153
|
+
metadata,
|
|
154
|
+
kind,
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
pub(crate) fn is_file(&self) -> bool {
|
|
159
|
+
self.kind == RootKind::File
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
pub(crate) fn match_root(&self) -> &Path {
|
|
163
|
+
if self.is_file() {
|
|
164
|
+
self.native.parent().unwrap_or(&self.native)
|
|
165
|
+
} else {
|
|
166
|
+
&self.native
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// The accepted filesystem shape for a tool's search root.
|
|
172
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
173
|
+
pub(crate) enum RootRequirement {
|
|
174
|
+
FileOrDirectory,
|
|
175
|
+
Directory,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/// An invalid complete FastCtx filename escape.
|
|
179
|
+
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
180
|
+
pub(crate) struct PathCodecError {
|
|
181
|
+
component: String,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
impl PathCodecError {
|
|
185
|
+
fn new(component: &str) -> Self {
|
|
186
|
+
Self {
|
|
187
|
+
component: component.to_string(),
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
impl fmt::Display for PathCodecError {
|
|
193
|
+
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
194
|
+
write!(
|
|
195
|
+
formatter,
|
|
196
|
+
"Invalid FastCtx-encoded path component \"{}\" {INVALID_TOKEN_SUFFIX}",
|
|
197
|
+
self.component
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
impl std::error::Error for PathCodecError {}
|
|
203
|
+
|
|
204
|
+
/// Parses a model-facing path, decoding canonical FastCtx components in plain paths exactly once.
|
|
205
|
+
pub(crate) fn parse_input_path(input: &str) -> Result<PathBuf, String> {
|
|
206
|
+
let is_file_uri = crate::paths::is_local_file_uri_input(input);
|
|
207
|
+
let input = crate::paths::parse_local_path_input(input)?;
|
|
208
|
+
if is_file_uri {
|
|
209
|
+
// A URI names native path components, while FastCtx escapes are a model-facing display
|
|
210
|
+
// format. Decoding URI components again would reinterpret a literal token-shaped name.
|
|
211
|
+
return Ok(input);
|
|
212
|
+
}
|
|
213
|
+
let mut decoded = PathBuf::new();
|
|
214
|
+
for component in input.components() {
|
|
215
|
+
match component {
|
|
216
|
+
Component::Prefix(prefix) => decoded.push(prefix.as_os_str()),
|
|
217
|
+
Component::RootDir => decoded.push(Path::new(std::path::MAIN_SEPARATOR_STR)),
|
|
218
|
+
Component::CurDir => decoded.push("."),
|
|
219
|
+
Component::ParentDir => decoded.push(".."),
|
|
220
|
+
Component::Normal(component) => {
|
|
221
|
+
decoded.push(decode_component_once(component).map_err(|error| error.to_string())?)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
Ok(decoded)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/// Produces a slash-separated path whose normal components are injective and line-safe.
|
|
229
|
+
pub(crate) fn display_path(path: &Path) -> String {
|
|
230
|
+
let mut display = String::new();
|
|
231
|
+
for component in path.components() {
|
|
232
|
+
match component {
|
|
233
|
+
Component::Prefix(prefix) => append_prefix(&mut display, prefix),
|
|
234
|
+
Component::RootDir => {
|
|
235
|
+
if !display.ends_with('/') {
|
|
236
|
+
display.push('/');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
Component::CurDir => append_display_component(&mut display, "."),
|
|
240
|
+
Component::ParentDir => append_display_component(&mut display, ".."),
|
|
241
|
+
Component::Normal(component) => {
|
|
242
|
+
let encoded = encode_component(component);
|
|
243
|
+
append_display_component(&mut display, &encoded);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
display
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/// Translates search-path IO failures without lossy or line-breaking path rendering.
|
|
251
|
+
pub(crate) fn io_error_message(path: &Path, error: &io::Error) -> String {
|
|
252
|
+
let display = display_path(path);
|
|
253
|
+
#[cfg(windows)]
|
|
254
|
+
if matches!(error.raw_os_error(), Some(32 | 33)) {
|
|
255
|
+
return format!("Cannot open file (locked by another process): {display}");
|
|
256
|
+
}
|
|
257
|
+
if error.kind() == io::ErrorKind::PermissionDenied {
|
|
258
|
+
return format!("Permission denied: {display}");
|
|
259
|
+
}
|
|
260
|
+
format!("Cannot open file: {display} ({error})")
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/// Resolves a grep/glob root with one explicit metadata call and no existence preflight.
|
|
264
|
+
pub(crate) fn resolve_search_root(
|
|
265
|
+
input: Option<&str>,
|
|
266
|
+
requirement: RootRequirement,
|
|
267
|
+
) -> Result<ResolvedRoot, String> {
|
|
268
|
+
let parsed = match input {
|
|
269
|
+
Some(input) => parse_input_path(input).map_err(|error| error.to_string())?,
|
|
270
|
+
None => crate::session::current_dir()
|
|
271
|
+
.map_err(|error| format!("Cannot access the session working directory: {error}"))?,
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
if input.is_some() && !parsed.is_absolute() {
|
|
275
|
+
return Err(missing_relative_search_path_message(&parsed));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let metadata = root_metadata(&parsed).map_err(|error| {
|
|
279
|
+
if error.kind() == io::ErrorKind::NotFound {
|
|
280
|
+
missing_absolute_search_path_message(&parsed)
|
|
281
|
+
} else {
|
|
282
|
+
io_error_message(&parsed, &error)
|
|
283
|
+
}
|
|
284
|
+
})?;
|
|
285
|
+
let kind = metadata_kind(&parsed, &metadata)?;
|
|
286
|
+
if requirement == RootRequirement::Directory && kind != RootKind::Directory {
|
|
287
|
+
return Err(format!(
|
|
288
|
+
"Path is not a directory: {}",
|
|
289
|
+
display_path(&parsed)
|
|
290
|
+
));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let canonical =
|
|
294
|
+
canonical_existing(&parsed).map_err(|error| io_error_message(&parsed, &error))?;
|
|
295
|
+
Ok(ResolvedRoot {
|
|
296
|
+
display: Arc::from(display_path(&canonical)),
|
|
297
|
+
native: canonical,
|
|
298
|
+
metadata,
|
|
299
|
+
kind,
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
fn metadata_kind(path: &Path, metadata: &Metadata) -> Result<RootKind, String> {
|
|
304
|
+
if metadata.is_file() {
|
|
305
|
+
Ok(RootKind::File)
|
|
306
|
+
} else if metadata.is_dir() {
|
|
307
|
+
Ok(RootKind::Directory)
|
|
308
|
+
} else {
|
|
309
|
+
Err(format!(
|
|
310
|
+
"Path is not a file or directory: {}",
|
|
311
|
+
display_path(path)
|
|
312
|
+
))
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
fn missing_absolute_search_path_message(parsed: &Path) -> String {
|
|
317
|
+
format!(
|
|
318
|
+
"Path does not exist: {}\nNote: the session working directory is {}.",
|
|
319
|
+
display_path(parsed),
|
|
320
|
+
current_dir_display()
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
fn missing_relative_search_path_message(parsed: &Path) -> String {
|
|
325
|
+
let cwd = crate::session::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
|
326
|
+
let resolved = cwd.join(parsed);
|
|
327
|
+
let mut note = format!(
|
|
328
|
+
"Note: the session working directory is {}.",
|
|
329
|
+
current_dir_display()
|
|
330
|
+
);
|
|
331
|
+
if fs::metadata(&resolved).is_ok()
|
|
332
|
+
&& let Ok(absolute) = canonical_existing(&resolved)
|
|
333
|
+
{
|
|
334
|
+
note.push_str(&format!(
|
|
335
|
+
" Use the absolute path {}.",
|
|
336
|
+
display_path(&absolute)
|
|
337
|
+
));
|
|
338
|
+
}
|
|
339
|
+
format!("Path does not exist: {}\n{note}", display_path(parsed))
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
fn current_dir_display() -> String {
|
|
343
|
+
crate::session::current_dir()
|
|
344
|
+
.ok()
|
|
345
|
+
.and_then(|path| canonical_existing(&path).ok().or(Some(path)))
|
|
346
|
+
.map(|path| display_path(&path))
|
|
347
|
+
.unwrap_or_else(|| ".".to_string())
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
#[cfg(test)]
|
|
351
|
+
thread_local! {
|
|
352
|
+
static ROOT_METADATA_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
fn root_metadata(path: &Path) -> io::Result<Metadata> {
|
|
356
|
+
#[cfg(test)]
|
|
357
|
+
ROOT_METADATA_CALLS.with(|calls| calls.set(calls.get() + 1));
|
|
358
|
+
fs::metadata(path)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
fn encode_component(component: &OsStr) -> String {
|
|
362
|
+
if let Some(text) = component.to_str() {
|
|
363
|
+
if component_is_safe(text) {
|
|
364
|
+
return text.to_string();
|
|
365
|
+
}
|
|
366
|
+
return byte_token(text.as_bytes());
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
#[cfg(unix)]
|
|
370
|
+
{
|
|
371
|
+
use std::os::unix::ffi::OsStrExt;
|
|
372
|
+
byte_token(component.as_bytes())
|
|
373
|
+
}
|
|
374
|
+
#[cfg(windows)]
|
|
375
|
+
{
|
|
376
|
+
use std::os::windows::ffi::OsStrExt;
|
|
377
|
+
windows_token(component.encode_wide())
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
fn component_is_safe(component: &str) -> bool {
|
|
382
|
+
!component
|
|
383
|
+
.chars()
|
|
384
|
+
.any(|character| character.is_control() || matches!(character, '\u{2028}' | '\u{2029}'))
|
|
385
|
+
&& (!cfg!(unix) || !component.contains('\\'))
|
|
386
|
+
&& canonical_token(component).is_none()
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
fn decode_component_once(component: &OsStr) -> Result<OsString, PathCodecError> {
|
|
390
|
+
let Some(text) = component.to_str() else {
|
|
391
|
+
return Ok(component.to_os_string());
|
|
392
|
+
};
|
|
393
|
+
let Some(token) = canonical_token(text) else {
|
|
394
|
+
return Ok(component.to_os_string());
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
let decoded = match token {
|
|
398
|
+
CanonicalToken::Bytes(payload) => decode_byte_component(payload, text)?,
|
|
399
|
+
CanonicalToken::Windows(payload) => decode_windows_component(payload, text)?,
|
|
400
|
+
};
|
|
401
|
+
if !is_single_normal_component(&decoded) {
|
|
402
|
+
return Err(PathCodecError::new(text));
|
|
403
|
+
}
|
|
404
|
+
Ok(decoded)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
fn decode_byte_component(payload: &str, source: &str) -> Result<OsString, PathCodecError> {
|
|
408
|
+
let bytes = decode_byte_pairs(payload);
|
|
409
|
+
#[cfg(unix)]
|
|
410
|
+
let decoded = {
|
|
411
|
+
use std::os::unix::ffi::OsStringExt;
|
|
412
|
+
OsString::from_vec(bytes.clone())
|
|
413
|
+
};
|
|
414
|
+
#[cfg(windows)]
|
|
415
|
+
let decoded = String::from_utf8(bytes.clone())
|
|
416
|
+
.map(OsString::from)
|
|
417
|
+
.map_err(|_| PathCodecError::new(source))?;
|
|
418
|
+
|
|
419
|
+
if byte_token(&bytes) != source {
|
|
420
|
+
return Err(PathCodecError::new(source));
|
|
421
|
+
}
|
|
422
|
+
Ok(decoded)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
#[cfg(unix)]
|
|
426
|
+
fn decode_windows_component(_payload: &str, source: &str) -> Result<OsString, PathCodecError> {
|
|
427
|
+
Err(PathCodecError::new(source))
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
#[cfg(windows)]
|
|
431
|
+
fn decode_windows_component(payload: &str, source: &str) -> Result<OsString, PathCodecError> {
|
|
432
|
+
use std::os::windows::ffi::OsStringExt;
|
|
433
|
+
let units = payload
|
|
434
|
+
.as_bytes()
|
|
435
|
+
.as_chunks::<4>()
|
|
436
|
+
.0
|
|
437
|
+
.iter()
|
|
438
|
+
.map(|bytes| decode_hex_u16(bytes))
|
|
439
|
+
.collect::<Vec<_>>();
|
|
440
|
+
if windows_token(units.iter().copied()) != source {
|
|
441
|
+
return Err(PathCodecError::new(source));
|
|
442
|
+
}
|
|
443
|
+
Ok(OsString::from_wide(&units))
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
fn is_single_normal_component(component: &OsStr) -> bool {
|
|
447
|
+
if native_component_has_forbidden_separator(component) {
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
let mut components = Path::new(component).components();
|
|
451
|
+
matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
#[cfg(unix)]
|
|
455
|
+
fn native_component_has_forbidden_separator(component: &OsStr) -> bool {
|
|
456
|
+
use std::os::unix::ffi::OsStrExt;
|
|
457
|
+
component
|
|
458
|
+
.as_bytes()
|
|
459
|
+
.iter()
|
|
460
|
+
.any(|byte| matches!(*byte, 0 | b'/'))
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
#[cfg(windows)]
|
|
464
|
+
fn native_component_has_forbidden_separator(component: &OsStr) -> bool {
|
|
465
|
+
use std::os::windows::ffi::OsStrExt;
|
|
466
|
+
component
|
|
467
|
+
.encode_wide()
|
|
468
|
+
.any(|unit| unit == 0 || unit == u16::from(b'/') || unit == u16::from(b'\\'))
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
472
|
+
enum CanonicalToken<'a> {
|
|
473
|
+
Bytes(&'a str),
|
|
474
|
+
Windows(&'a str),
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
fn canonical_token(component: &str) -> Option<CanonicalToken<'_>> {
|
|
478
|
+
if let Some(payload) = component
|
|
479
|
+
.strip_prefix(BYTE_TOKEN_PREFIX)
|
|
480
|
+
.and_then(|payload| payload.strip_suffix(TOKEN_SUFFIX))
|
|
481
|
+
&& !payload.is_empty()
|
|
482
|
+
&& payload.len().is_multiple_of(2)
|
|
483
|
+
&& payload.bytes().all(is_lower_hex)
|
|
484
|
+
{
|
|
485
|
+
return Some(CanonicalToken::Bytes(payload));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if let Some(payload) = component
|
|
489
|
+
.strip_prefix(WINDOWS_TOKEN_PREFIX)
|
|
490
|
+
.and_then(|payload| payload.strip_suffix(TOKEN_SUFFIX))
|
|
491
|
+
&& !payload.is_empty()
|
|
492
|
+
&& payload.len().is_multiple_of(4)
|
|
493
|
+
&& payload.bytes().all(is_lower_hex)
|
|
494
|
+
{
|
|
495
|
+
return Some(CanonicalToken::Windows(payload));
|
|
496
|
+
}
|
|
497
|
+
None
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
fn is_lower_hex(byte: u8) -> bool {
|
|
501
|
+
byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
fn byte_token(bytes: &[u8]) -> String {
|
|
505
|
+
let mut encoded = String::with_capacity(BYTE_TOKEN_PREFIX.len() + bytes.len() * 2 + 1);
|
|
506
|
+
encoded.push_str(BYTE_TOKEN_PREFIX);
|
|
507
|
+
append_lower_hex(&mut encoded, bytes);
|
|
508
|
+
encoded.push(TOKEN_SUFFIX);
|
|
509
|
+
encoded
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
#[cfg(windows)]
|
|
513
|
+
fn windows_token(units: impl IntoIterator<Item = u16>) -> String {
|
|
514
|
+
use std::fmt::Write;
|
|
515
|
+
let mut encoded = String::from(WINDOWS_TOKEN_PREFIX);
|
|
516
|
+
for unit in units {
|
|
517
|
+
write!(&mut encoded, "{unit:04x}").expect("writing to a String cannot fail");
|
|
518
|
+
}
|
|
519
|
+
encoded.push(TOKEN_SUFFIX);
|
|
520
|
+
encoded
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
fn append_lower_hex(output: &mut String, bytes: &[u8]) {
|
|
524
|
+
const HEX: &[u8; 16] = b"0123456789abcdef";
|
|
525
|
+
for byte in bytes {
|
|
526
|
+
output.push(HEX[(byte >> 4) as usize] as char);
|
|
527
|
+
output.push(HEX[(byte & 0x0f) as usize] as char);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
fn decode_byte_pairs(payload: &str) -> Vec<u8> {
|
|
532
|
+
payload
|
|
533
|
+
.as_bytes()
|
|
534
|
+
.as_chunks::<2>()
|
|
535
|
+
.0
|
|
536
|
+
.iter()
|
|
537
|
+
.map(|pair| (decode_hex_nibble(pair[0]) << 4) | decode_hex_nibble(pair[1]))
|
|
538
|
+
.collect()
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
fn decode_hex_nibble(byte: u8) -> u8 {
|
|
542
|
+
match byte {
|
|
543
|
+
b'0'..=b'9' => byte - b'0',
|
|
544
|
+
b'a'..=b'f' => byte - b'a' + 10,
|
|
545
|
+
_ => unreachable!("canonical token grammar already validated lowercase hex"),
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
#[cfg(windows)]
|
|
550
|
+
fn decode_hex_u16(bytes: &[u8]) -> u16 {
|
|
551
|
+
bytes.iter().fold(0_u16, |value, byte| {
|
|
552
|
+
(value << 4) | u16::from(decode_hex_nibble(*byte))
|
|
553
|
+
})
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
#[cfg(unix)]
|
|
557
|
+
fn native_path_key(path: &Path) -> NativePathKey {
|
|
558
|
+
use std::os::unix::ffi::OsStrExt;
|
|
559
|
+
NativePathKey(path.as_os_str().as_bytes().to_vec())
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
#[cfg(windows)]
|
|
563
|
+
fn native_path_key(path: &Path) -> NativePathKey {
|
|
564
|
+
use std::os::windows::ffi::OsStrExt;
|
|
565
|
+
NativePathKey(path.as_os_str().encode_wide().collect())
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
fn append_display_component(display: &mut String, component: &str) {
|
|
569
|
+
if !display.is_empty() && !display.ends_with('/') {
|
|
570
|
+
display.push('/');
|
|
571
|
+
}
|
|
572
|
+
display.push_str(component);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
#[cfg(unix)]
|
|
576
|
+
fn append_prefix(_display: &mut str, prefix: std::path::PrefixComponent<'_>) {
|
|
577
|
+
unreachable!("prefix components cannot be produced by Unix paths: {prefix:?}")
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
#[cfg(windows)]
|
|
581
|
+
fn append_prefix(display: &mut String, prefix: std::path::PrefixComponent<'_>) {
|
|
582
|
+
use std::path::Prefix;
|
|
583
|
+
match prefix.kind() {
|
|
584
|
+
Prefix::Disk(drive) | Prefix::VerbatimDisk(drive) => {
|
|
585
|
+
display.push((drive as char).to_ascii_uppercase());
|
|
586
|
+
display.push(':');
|
|
587
|
+
}
|
|
588
|
+
Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {
|
|
589
|
+
display.push_str("//");
|
|
590
|
+
display.push_str(&encode_component(server));
|
|
591
|
+
display.push('/');
|
|
592
|
+
display.push_str(&encode_component(share));
|
|
593
|
+
}
|
|
594
|
+
Prefix::DeviceNS(device) => {
|
|
595
|
+
display.push_str("//./");
|
|
596
|
+
display.push_str(&encode_component(device));
|
|
597
|
+
}
|
|
598
|
+
Prefix::Verbatim(value) => display.push_str(&encode_component(value)),
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
#[cfg(test)]
|
|
603
|
+
mod tests {
|
|
604
|
+
use super::{
|
|
605
|
+
ROOT_METADATA_CALLS, RootKind, RootRequirement, display_path, io_error_message,
|
|
606
|
+
parse_input_path, resolve_search_root,
|
|
607
|
+
};
|
|
608
|
+
use std::path::{Path, PathBuf};
|
|
609
|
+
|
|
610
|
+
#[test]
|
|
611
|
+
fn portable_special_unicode_has_hard_coded_canonical_bytes() {
|
|
612
|
+
assert_eq!(
|
|
613
|
+
display_path(Path::new("line\u{2028}break.txt")),
|
|
614
|
+
"~fastctx~b6c696e65e280a8627265616b2e747874~"
|
|
615
|
+
);
|
|
616
|
+
assert_eq!(
|
|
617
|
+
display_path(Path::new("line\u{2029}break.txt")),
|
|
618
|
+
"~fastctx~b6c696e65e280a9627265616b2e747874~"
|
|
619
|
+
);
|
|
620
|
+
assert_eq!(
|
|
621
|
+
display_path(Path::new("tab\tname.txt")),
|
|
622
|
+
"~fastctx~b746162096e616d652e747874~"
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
#[test]
|
|
627
|
+
fn ordinary_safe_unicode_components_remain_verbatim() {
|
|
628
|
+
for path in [
|
|
629
|
+
"alpha.txt",
|
|
630
|
+
"nested/child.rs",
|
|
631
|
+
"目录/文件.txt",
|
|
632
|
+
"emoji-😀.txt",
|
|
633
|
+
"~fastctx~notes",
|
|
634
|
+
"~fastctx~bzz~",
|
|
635
|
+
] {
|
|
636
|
+
assert_eq!(display_path(Path::new(path)), path);
|
|
637
|
+
assert_eq!(parse_input_path(path).unwrap(), PathBuf::from(path));
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
#[test]
|
|
642
|
+
fn every_unicode_control_component_is_line_safe_and_reversible() {
|
|
643
|
+
for character in (1_u32..=31).chain([127]) {
|
|
644
|
+
let character = char::from_u32(character).unwrap();
|
|
645
|
+
let native = format!("left{character}right");
|
|
646
|
+
let encoded = display_path(Path::new(&native));
|
|
647
|
+
assert!(encoded.starts_with("~fastctx~b"), "{encoded}");
|
|
648
|
+
assert!(!encoded.chars().any(char::is_control), "{encoded:?}");
|
|
649
|
+
assert_eq!(parse_input_path(&encoded).unwrap(), PathBuf::from(native));
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
#[test]
|
|
654
|
+
fn malformed_token_shapes_are_literals_but_valid_dangerous_payloads_fail_closed() {
|
|
655
|
+
for literal in [
|
|
656
|
+
"~fastctx~notes",
|
|
657
|
+
"~fastctx~backup",
|
|
658
|
+
"~fastctx~bzz~",
|
|
659
|
+
"~fastctx~bA0~",
|
|
660
|
+
"~fastctx~b0~",
|
|
661
|
+
"~fastctx~x61~",
|
|
662
|
+
] {
|
|
663
|
+
assert_eq!(parse_input_path(literal).unwrap(), PathBuf::from(literal));
|
|
664
|
+
assert_eq!(display_path(Path::new(literal)), literal);
|
|
665
|
+
}
|
|
666
|
+
for invalid in ["~fastctx~b00~", "~fastctx~b2f~", "~fastctx~b2e~"] {
|
|
667
|
+
assert_eq!(
|
|
668
|
+
parse_input_path(invalid).unwrap_err().to_string(),
|
|
669
|
+
format!(
|
|
670
|
+
"Invalid FastCtx-encoded path component \"{invalid}\" for this platform. Copy an encoded path exactly as returned by FastCtx."
|
|
671
|
+
)
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
#[test]
|
|
677
|
+
fn successful_root_resolution_uses_one_explicit_metadata_call() {
|
|
678
|
+
let temp = tempfile::tempdir().unwrap();
|
|
679
|
+
ROOT_METADATA_CALLS.with(|calls| calls.set(0));
|
|
680
|
+
let root = resolve_search_root(
|
|
681
|
+
Some(temp.path().to_str().unwrap()),
|
|
682
|
+
RootRequirement::Directory,
|
|
683
|
+
)
|
|
684
|
+
.unwrap();
|
|
685
|
+
assert_eq!(root.native, dunce::canonicalize(temp.path()).unwrap());
|
|
686
|
+
ROOT_METADATA_CALLS.with(|calls| assert_eq!(calls.get(), 1));
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
#[test]
|
|
690
|
+
fn file_and_missing_root_resolution_each_use_one_explicit_metadata_call() {
|
|
691
|
+
let temp = tempfile::tempdir().unwrap();
|
|
692
|
+
let file = temp.path().join("single.txt");
|
|
693
|
+
std::fs::write(&file, b"stable").unwrap();
|
|
694
|
+
|
|
695
|
+
ROOT_METADATA_CALLS.with(|calls| calls.set(0));
|
|
696
|
+
let resolved = resolve_search_root(
|
|
697
|
+
Some(file.to_str().unwrap()),
|
|
698
|
+
RootRequirement::FileOrDirectory,
|
|
699
|
+
)
|
|
700
|
+
.unwrap();
|
|
701
|
+
assert_eq!(resolved.kind, RootKind::File);
|
|
702
|
+
ROOT_METADATA_CALLS.with(|calls| assert_eq!(calls.get(), 1));
|
|
703
|
+
|
|
704
|
+
let missing = temp.path().join("missing.txt");
|
|
705
|
+
ROOT_METADATA_CALLS.with(|calls| calls.set(0));
|
|
706
|
+
let error = resolve_search_root(
|
|
707
|
+
Some(missing.to_str().unwrap()),
|
|
708
|
+
RootRequirement::FileOrDirectory,
|
|
709
|
+
)
|
|
710
|
+
.unwrap_err();
|
|
711
|
+
assert!(error.starts_with(&format!(
|
|
712
|
+
"Path does not exist: {}\n",
|
|
713
|
+
display_path(&missing)
|
|
714
|
+
)));
|
|
715
|
+
ROOT_METADATA_CALLS.with(|calls| assert_eq!(calls.get(), 1));
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
#[test]
|
|
719
|
+
fn io_errors_keep_specific_kind_and_line_safe_path() {
|
|
720
|
+
let path = Path::new("line\nname.txt");
|
|
721
|
+
let display = "~fastctx~b6c696e650a6e616d652e747874~";
|
|
722
|
+
let denied = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
|
|
723
|
+
assert_eq!(
|
|
724
|
+
io_error_message(path, &denied),
|
|
725
|
+
format!("Permission denied: {display}")
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
let other = std::io::Error::other("disk fault");
|
|
729
|
+
assert_eq!(
|
|
730
|
+
io_error_message(path, &other),
|
|
731
|
+
format!("Cannot open file: {display} (disk fault)")
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
#[cfg(windows)]
|
|
736
|
+
#[test]
|
|
737
|
+
fn windows_sharing_violation_has_the_locked_error_contract() {
|
|
738
|
+
let error = std::io::Error::from_raw_os_error(32);
|
|
739
|
+
assert_eq!(
|
|
740
|
+
io_error_message(Path::new(r"C:\locked.txt"), &error),
|
|
741
|
+
"Cannot open file (locked by another process): C:/locked.txt"
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
#[cfg(unix)]
|
|
746
|
+
#[test]
|
|
747
|
+
fn unix_invalid_bytes_and_literal_backslash_are_lossless_and_distinct() {
|
|
748
|
+
use std::ffi::OsString;
|
|
749
|
+
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
|
750
|
+
|
|
751
|
+
let invalid = PathBuf::from(OsString::from_vec(vec![0xff]));
|
|
752
|
+
assert_eq!(display_path(&invalid), "~fastctx~bff~");
|
|
753
|
+
assert_eq!(
|
|
754
|
+
parse_input_path("~fastctx~bff~")
|
|
755
|
+
.unwrap()
|
|
756
|
+
.as_os_str()
|
|
757
|
+
.as_bytes(),
|
|
758
|
+
&[0xff]
|
|
759
|
+
);
|
|
760
|
+
assert_eq!(
|
|
761
|
+
display_path(Path::new("a\\b.txt")),
|
|
762
|
+
"~fastctx~b615c622e747874~"
|
|
763
|
+
);
|
|
764
|
+
assert_ne!(
|
|
765
|
+
display_path(Path::new("a\\b.txt")),
|
|
766
|
+
display_path(Path::new("a/b.txt"))
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
#[cfg(unix)]
|
|
771
|
+
#[test]
|
|
772
|
+
fn unix_single_byte_components_are_injective_and_round_trip() {
|
|
773
|
+
use std::collections::BTreeSet;
|
|
774
|
+
use std::ffi::OsString;
|
|
775
|
+
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
|
776
|
+
|
|
777
|
+
let mut displays = BTreeSet::new();
|
|
778
|
+
for byte in 1_u8..=u8::MAX {
|
|
779
|
+
if matches!(byte, b'/' | b'.') {
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
let native = PathBuf::from(OsString::from_vec(vec![byte]));
|
|
783
|
+
let display = display_path(&native);
|
|
784
|
+
assert!(displays.insert(display.clone()), "collision at {byte:#x}");
|
|
785
|
+
assert_eq!(
|
|
786
|
+
parse_input_path(&display).unwrap().as_os_str().as_bytes(),
|
|
787
|
+
&[byte]
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
#[cfg(unix)]
|
|
793
|
+
#[test]
|
|
794
|
+
fn canonical_windows_token_is_reserved_and_rejected_on_unix() {
|
|
795
|
+
let token = "~fastctx~w0061~";
|
|
796
|
+
assert_eq!(
|
|
797
|
+
parse_input_path(token).unwrap_err().to_string(),
|
|
798
|
+
"Invalid FastCtx-encoded path component \"~fastctx~w0061~\" for this platform. Copy an encoded path exactly as returned by FastCtx."
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
#[cfg(windows)]
|
|
803
|
+
#[test]
|
|
804
|
+
fn windows_unpaired_surrogate_uses_w_token_and_round_trips() {
|
|
805
|
+
use std::ffi::OsString;
|
|
806
|
+
use std::os::windows::ffi::{OsStrExt, OsStringExt};
|
|
807
|
+
|
|
808
|
+
let native = PathBuf::from(OsString::from_wide(&[0xd800]));
|
|
809
|
+
assert_eq!(display_path(&native), "~fastctx~wd800~");
|
|
810
|
+
assert_eq!(
|
|
811
|
+
parse_input_path("~fastctx~wd800~")
|
|
812
|
+
.unwrap()
|
|
813
|
+
.as_os_str()
|
|
814
|
+
.encode_wide()
|
|
815
|
+
.collect::<Vec<_>>(),
|
|
816
|
+
vec![0xd800]
|
|
817
|
+
);
|
|
818
|
+
assert!(parse_input_path("~fastctx~bff~").is_err());
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
#[cfg(windows)]
|
|
822
|
+
#[test]
|
|
823
|
+
fn windows_unpaired_surrogate_sequences_are_injective() {
|
|
824
|
+
use std::collections::BTreeSet;
|
|
825
|
+
use std::ffi::OsString;
|
|
826
|
+
use std::os::windows::ffi::{OsStrExt, OsStringExt};
|
|
827
|
+
|
|
828
|
+
let cases: &[&[u16]] = &[
|
|
829
|
+
&[0xd800],
|
|
830
|
+
&[0xdfff],
|
|
831
|
+
&[0x0061, 0xd800],
|
|
832
|
+
&[0xd800, 0x0061],
|
|
833
|
+
&[0xd800, 0xdfff, 0xd800],
|
|
834
|
+
];
|
|
835
|
+
let mut displays = BTreeSet::new();
|
|
836
|
+
for units in cases {
|
|
837
|
+
let native = PathBuf::from(OsString::from_wide(units));
|
|
838
|
+
let display = display_path(&native);
|
|
839
|
+
assert!(displays.insert(display.clone()), "{display}");
|
|
840
|
+
assert_eq!(
|
|
841
|
+
parse_input_path(&display)
|
|
842
|
+
.unwrap()
|
|
843
|
+
.as_os_str()
|
|
844
|
+
.encode_wide()
|
|
845
|
+
.collect::<Vec<_>>(),
|
|
846
|
+
*units
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|