dsh-ops 0.0.0-stage → 0.2.1

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.
Files changed (139) hide show
  1. package/CHANGELOG.md +189 -0
  2. package/LICENSE +30 -0
  3. package/NOTICE +106 -0
  4. package/PROVENANCE.md +417 -0
  5. package/README.en.md +121 -0
  6. package/README.md +113 -2
  7. package/README.zh.md +114 -0
  8. package/bin/dsh-ops.mjs +1216 -0
  9. package/cordis.patch.yml +160 -0
  10. package/docs/manual-validation.md +53 -0
  11. package/docs/schema-baseline.json +64 -0
  12. package/docs/schema-current.json +84 -0
  13. package/docs/schema-measurement.md +17 -0
  14. package/dsh-plugin.json +88 -0
  15. package/icon.svg +12 -0
  16. package/lib/binary.js +409 -0
  17. package/lib/config.js +198 -0
  18. package/lib/handshake.js +252 -0
  19. package/lib/index.js +108 -0
  20. package/lib/jobs.js +42 -0
  21. package/lib/policy.js +64 -0
  22. package/lib/presentation.js +63 -0
  23. package/lib/profile-install.js +61 -0
  24. package/lib/rust.js +194 -0
  25. package/lib/session-shells.js +78 -0
  26. package/lib/shells.js +993 -0
  27. package/lib/tools.js +657 -0
  28. package/locale/en.json +6 -0
  29. package/locale/zh.json +6 -0
  30. package/package.json +114 -4
  31. package/vendor/fastctx/Cargo.lock +3210 -0
  32. package/vendor/fastctx/Cargo.toml +94 -0
  33. package/vendor/fastctx/FORK.md +119 -0
  34. package/vendor/fastctx/LICENSE-APACHE +201 -0
  35. package/vendor/fastctx/NOTICE +40 -0
  36. package/vendor/fastctx/README.md +439 -0
  37. package/vendor/fastctx/THIRD_PARTY_LICENSES.md +17 -0
  38. package/vendor/fastctx/THIRD_PARTY_LICENSES_RUST.md +7914 -0
  39. package/vendor/fastctx/UPSTREAM.md +49 -0
  40. package/vendor/fastctx/build.rs +413 -0
  41. package/vendor/fastctx/src/background_status.rs +403 -0
  42. package/vendor/fastctx/src/binary.rs +75 -0
  43. package/vendor/fastctx/src/bounded_sort.rs +500 -0
  44. package/vendor/fastctx/src/budget.rs +781 -0
  45. package/vendor/fastctx/src/cli/mod.rs +110 -0
  46. package/vendor/fastctx/src/context_guard.rs +289 -0
  47. package/vendor/fastctx/src/control/mod.rs +6 -0
  48. package/vendor/fastctx/src/control/paths.rs +49 -0
  49. package/vendor/fastctx/src/control/settings.rs +753 -0
  50. package/vendor/fastctx/src/control/transaction.rs +531 -0
  51. package/vendor/fastctx/src/edit/document.rs +535 -0
  52. package/vendor/fastctx/src/edit/locks.rs +371 -0
  53. package/vendor/fastctx/src/edit/mod.rs +213 -0
  54. package/vendor/fastctx/src/edit/private_storage/unix.rs +315 -0
  55. package/vendor/fastctx/src/edit/private_storage/windows.rs +793 -0
  56. package/vendor/fastctx/src/edit/private_storage.rs +234 -0
  57. package/vendor/fastctx/src/edit/replace.rs +1030 -0
  58. package/vendor/fastctx/src/edit_server.rs +53 -0
  59. package/vendor/fastctx/src/encoding/reference_v011.rs +587 -0
  60. package/vendor/fastctx/src/encoding/snapshot_pipeline.rs +1678 -0
  61. package/vendor/fastctx/src/encoding.rs +1118 -0
  62. package/vendor/fastctx/src/file_executor.rs +1151 -0
  63. package/vendor/fastctx/src/file_snapshot.rs +1491 -0
  64. package/vendor/fastctx/src/glob_filter.rs +98 -0
  65. package/vendor/fastctx/src/glob_tool.rs +653 -0
  66. package/vendor/fastctx/src/grep_sink.rs +1162 -0
  67. package/vendor/fastctx/src/grep_tool.rs +2449 -0
  68. package/vendor/fastctx/src/lib.rs +45 -0
  69. package/vendor/fastctx/src/main.rs +15 -0
  70. package/vendor/fastctx/src/model.rs +51 -0
  71. package/vendor/fastctx/src/model_guidance.rs +62 -0
  72. package/vendor/fastctx/src/operation.rs +356 -0
  73. package/vendor/fastctx/src/ordered_window.rs +1235 -0
  74. package/vendor/fastctx/src/os_environment.rs +414 -0
  75. package/vendor/fastctx/src/path_codec.rs +850 -0
  76. package/vendor/fastctx/src/paths.rs +244 -0
  77. package/vendor/fastctx/src/process_identity.rs +763 -0
  78. package/vendor/fastctx/src/process_policy.rs +74 -0
  79. package/vendor/fastctx/src/read_tool/batch.rs +496 -0
  80. package/vendor/fastctx/src/read_tool/hex_file.rs +141 -0
  81. package/vendor/fastctx/src/read_tool/image_file.rs +88 -0
  82. package/vendor/fastctx/src/read_tool/mod.rs +245 -0
  83. package/vendor/fastctx/src/read_tool/pdf.rs +470 -0
  84. package/vendor/fastctx/src/read_tool/pdf_disabled.rs +47 -0
  85. package/vendor/fastctx/src/read_tool/pdf_engine.rs +664 -0
  86. package/vendor/fastctx/src/read_tool/text_file.rs +351 -0
  87. package/vendor/fastctx/src/render_plan.rs +468 -0
  88. package/vendor/fastctx/src/runtime/activity.rs +159 -0
  89. package/vendor/fastctx/src/runtime/hosts.rs +99 -0
  90. package/vendor/fastctx/src/runtime/journal.rs +556 -0
  91. package/vendor/fastctx/src/runtime/local_ipc.rs +186 -0
  92. package/vendor/fastctx/src/runtime/mod.rs +746 -0
  93. package/vendor/fastctx/src/runtime/protocol.rs +296 -0
  94. package/vendor/fastctx/src/runtime/session.rs +536 -0
  95. package/vendor/fastctx/src/runtime/windows_process.rs +66 -0
  96. package/vendor/fastctx/src/search_parallelism.rs +106 -0
  97. package/vendor/fastctx/src/search_text.rs +227 -0
  98. package/vendor/fastctx/src/server.rs +359 -0
  99. package/vendor/fastctx/src/server_manifest.rs +468 -0
  100. package/vendor/fastctx/src/server_support.rs +826 -0
  101. package/vendor/fastctx/src/session.rs +629 -0
  102. package/vendor/fastctx/src/shell/apply_patch_hint.rs +41 -0
  103. package/vendor/fastctx/src/shell/bash.rs +263 -0
  104. package/vendor/fastctx/src/shell/buffer.rs +108 -0
  105. package/vendor/fastctx/src/shell/encoding.rs +403 -0
  106. package/vendor/fastctx/src/shell/foreground.rs +115 -0
  107. package/vendor/fastctx/src/shell/jobs/admission.rs +91 -0
  108. package/vendor/fastctx/src/shell/jobs/background.rs +146 -0
  109. package/vendor/fastctx/src/shell/jobs/host.rs +830 -0
  110. package/vendor/fastctx/src/shell/jobs/identity.rs +29 -0
  111. package/vendor/fastctx/src/shell/jobs/mod.rs +1513 -0
  112. package/vendor/fastctx/src/shell/jobs/model.rs +244 -0
  113. package/vendor/fastctx/src/shell/jobs/output_log.rs +1148 -0
  114. package/vendor/fastctx/src/shell/jobs/store.rs +1300 -0
  115. package/vendor/fastctx/src/shell/mod.rs +345 -0
  116. package/vendor/fastctx/src/shell/normalize.rs +389 -0
  117. package/vendor/fastctx/src/shell/output.rs +406 -0
  118. package/vendor/fastctx/src/shell/process.rs +493 -0
  119. package/vendor/fastctx/src/shell_server.rs +156 -0
  120. package/vendor/fastctx/src/skip_report.rs +83 -0
  121. package/vendor/fastctx/src/stdio_transport.rs +177 -0
  122. package/vendor/fastctx/src/tool_schema.rs +204 -0
  123. package/vendor/fastctx/src/traversal.rs +846 -0
  124. package/vendor/fastctx/third-party/pdfium-7763/LICENSE +9 -0
  125. package/vendor/fastctx/third-party/pdfium-7763/licenses/abseil.txt +202 -0
  126. package/vendor/fastctx/third-party/pdfium-7763/licenses/agg23.txt +14 -0
  127. package/vendor/fastctx/third-party/pdfium-7763/licenses/fast_float.txt +27 -0
  128. package/vendor/fastctx/third-party/pdfium-7763/licenses/freetype.txt +169 -0
  129. package/vendor/fastctx/third-party/pdfium-7763/licenses/icu.txt +542 -0
  130. package/vendor/fastctx/third-party/pdfium-7763/licenses/lcms.txt +27 -0
  131. package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.ijg +260 -0
  132. package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.md +135 -0
  133. package/vendor/fastctx/third-party/pdfium-7763/licenses/libopenjpeg.txt +32 -0
  134. package/vendor/fastctx/third-party/pdfium-7763/licenses/libpng.txt +134 -0
  135. package/vendor/fastctx/third-party/pdfium-7763/licenses/libtiff.txt +21 -0
  136. package/vendor/fastctx/third-party/pdfium-7763/licenses/llvm-libc.txt +278 -0
  137. package/vendor/fastctx/third-party/pdfium-7763/licenses/pdfium.txt +230 -0
  138. package/vendor/fastctx/third-party/pdfium-7763/licenses/simdutf.txt +18 -0
  139. package/vendor/fastctx/third-party/pdfium-7763/licenses/zlib.txt +29 -0
@@ -0,0 +1,535 @@
1
+ // Modified by dsh-ops (https://github.com/T-Auto/dsh-ops): dropped the removed transaction parameter that only the deleted self-updater passed as true.
2
+ //! Byte-preserving editable text snapshots, line anchors, CAS, and atomic commit.
3
+
4
+ use crate::control::transaction;
5
+ use crate::encoding::{EncodingDecision, ValidatedFileEncoding};
6
+ use crate::paths::{display_path, io_error_message, missing_file_message, parse_input_path};
7
+ use sha2::{Digest, Sha256};
8
+ use std::fs;
9
+ use std::io::Read;
10
+ use std::path::{Path, PathBuf};
11
+
12
+ #[cfg(test)]
13
+ type BeforeCommitHook = Box<dyn FnOnce(&Path)>;
14
+
15
+ #[cfg(test)]
16
+ thread_local! {
17
+ static BEFORE_COMMIT: std::cell::RefCell<Option<BeforeCommitHook>> =
18
+ std::cell::RefCell::new(None);
19
+ }
20
+
21
+ const MIB: u64 = 1024 * 1024;
22
+ const OFFSET_CHUNK_BYTES: usize = 64 * 1024;
23
+
24
+ /// Newline style used for every boundary introduced by an edit.
25
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
26
+ pub(crate) enum EolStyle {
27
+ Lf,
28
+ Crlf,
29
+ }
30
+
31
+ impl EolStyle {
32
+ pub(crate) fn as_str(self) -> &'static str {
33
+ match self {
34
+ Self::Lf => "\n",
35
+ Self::Crlf => "\r\n",
36
+ }
37
+ }
38
+ }
39
+
40
+ struct LogicalView {
41
+ logical: String,
42
+ source_line_endings: SourceLineEndings,
43
+ eol: EolStyle,
44
+ }
45
+
46
+ #[derive(Clone, Debug, Default)]
47
+ struct SourceLineEndings {
48
+ crlf_bits: Vec<u64>,
49
+ len: usize,
50
+ }
51
+
52
+ impl SourceLineEndings {
53
+ fn push(&mut self, is_crlf: bool) {
54
+ let index = self.len;
55
+ if index.is_multiple_of(64) {
56
+ self.crlf_bits.push(0);
57
+ }
58
+ if is_crlf {
59
+ self.crlf_bits[index / 64] |= 1_u64 << (index % 64);
60
+ }
61
+ self.len = self.len.saturating_add(1);
62
+ }
63
+
64
+ fn is_crlf(&self, index: usize) -> bool {
65
+ debug_assert!(index < self.len);
66
+ self.crlf_bits
67
+ .get(index / 64)
68
+ .is_some_and(|bits| bits & (1_u64 << (index % 64)) != 0)
69
+ }
70
+ }
71
+
72
+ /// Frozen source bytes and every derived view needed for safe line edits.
73
+ #[derive(Clone, Debug)]
74
+ pub(crate) struct TextDocument {
75
+ requested_path: PathBuf,
76
+ target_path: PathBuf,
77
+ raw: Vec<u8>,
78
+ validated: ValidatedFileEncoding,
79
+ logical: String,
80
+ source_line_endings: SourceLineEndings,
81
+ eol: EolStyle,
82
+ trailing_newline: bool,
83
+ unix_mode: Option<u32>,
84
+ }
85
+
86
+ impl TextDocument {
87
+ /// Opens one absolute regular-file target, following symlinks while preserving the link itself.
88
+ pub(crate) fn open(
89
+ file_path: &str,
90
+ encoding: Option<&str>,
91
+ max_file_size_mib: u64,
92
+ ) -> Result<Self, String> {
93
+ let requested_path = parse_input_path(file_path);
94
+ if !requested_path.is_absolute() {
95
+ return Err(missing_file_message(file_path));
96
+ }
97
+ match fs::symlink_metadata(&requested_path) {
98
+ Ok(_) => {}
99
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
100
+ return Err(missing_file_message(file_path));
101
+ }
102
+ Err(error) => return Err(io_error_message(&requested_path, &error)),
103
+ }
104
+ let target_path = resolve_target(&requested_path)?;
105
+ reject_hard_link(&target_path)?;
106
+ let metadata =
107
+ fs::metadata(&target_path).map_err(|error| io_error_message(&target_path, &error))?;
108
+ if !metadata.is_file() {
109
+ return Err(format!(
110
+ "Cannot edit non-regular file: {}. Only regular files are supported.",
111
+ display_path(&requested_path)
112
+ ));
113
+ }
114
+ let maximum_bytes = max_file_size_mib.saturating_mul(MIB);
115
+ let raw = match read_limited(&target_path, maximum_bytes)? {
116
+ LimitedRead::Bytes(raw) => raw,
117
+ LimitedRead::TooLarge(actual_bytes) => {
118
+ return Err(file_too_large_message(
119
+ &requested_path,
120
+ actual_bytes,
121
+ max_file_size_mib,
122
+ ));
123
+ }
124
+ };
125
+ let validated = match crate::encoding::validate_file_encoding(&target_path, encoding)
126
+ .map_err(|error| io_error_message(&target_path, &error))?
127
+ {
128
+ EncodingDecision::Text(validated) => validated,
129
+ EncodingDecision::Binary => {
130
+ return Err(format!(
131
+ "Cannot read binary file as text: {}. Use view=\"hex\" to inspect its raw bytes.",
132
+ display_path(&requested_path)
133
+ ));
134
+ }
135
+ EncodingDecision::Rejected(rejection) => {
136
+ return Err(rejection.message(&display_path(&requested_path)));
137
+ }
138
+ };
139
+ if !file_matches_bytes(&target_path, &raw)? {
140
+ return Err(concurrent_message(&requested_path));
141
+ }
142
+ let editable = validated.decode_editable_snapshot(&raw).map_err(|reason| {
143
+ format!(
144
+ "Cannot safely edit {}: {reason}. Convert the file to UTF-8 externally and retry.",
145
+ display_path(&requested_path)
146
+ )
147
+ })?;
148
+ let LogicalView {
149
+ logical,
150
+ source_line_endings,
151
+ eol,
152
+ } = logical_view(editable);
153
+ let trailing_newline = logical.ends_with('\n');
154
+ let unix_mode = transaction::existing_unix_mode(&target_path);
155
+ Ok(Self {
156
+ requested_path,
157
+ target_path,
158
+ raw,
159
+ validated,
160
+ logical,
161
+ source_line_endings,
162
+ eol,
163
+ trailing_newline,
164
+ unix_mode,
165
+ })
166
+ }
167
+
168
+ pub(crate) fn display_path(&self) -> String {
169
+ display_path(&self.requested_path)
170
+ }
171
+
172
+ pub(crate) fn target_path(&self) -> &Path {
173
+ &self.target_path
174
+ }
175
+
176
+ pub(crate) fn original_bytes(&self) -> &[u8] {
177
+ &self.raw
178
+ }
179
+
180
+ pub(crate) fn logical_text(&self) -> &str {
181
+ &self.logical
182
+ }
183
+
184
+ pub(crate) fn trailing_newline(&self) -> bool {
185
+ self.trailing_newline
186
+ }
187
+
188
+ pub(crate) fn encoding_label(&self) -> &'static str {
189
+ self.validated.encoding_label()
190
+ }
191
+
192
+ pub(crate) fn revision(&self) -> String {
193
+ hex::encode(Sha256::digest(&self.raw))
194
+ }
195
+
196
+ /// Commits one frozen snapshot if and only if the target still equals B0.
197
+ pub(crate) fn commit(mut self, new_bytes: &[u8]) -> Result<(), String> {
198
+ #[cfg(test)]
199
+ run_before_commit_hook(&self.target_path);
200
+ drop(std::mem::take(&mut self.logical));
201
+ if !file_matches_bytes(&self.target_path, &self.raw)? {
202
+ return Err(concurrent_message(&self.requested_path));
203
+ }
204
+ reject_hard_link(&self.target_path)?;
205
+ drop(std::mem::take(&mut self.raw));
206
+ transaction::atomic_replace(&self.target_path, new_bytes, self.unix_mode)
207
+ }
208
+
209
+ pub(crate) fn encode_for_target(&self, logical_text: &str) -> Result<Vec<u8>, String> {
210
+ let text = logical_text.replace('\n', self.eol.as_str());
211
+ self.validated.encode_fragment(&text).ok_or_else(|| {
212
+ format!(
213
+ "Cannot write {}: the replacement text cannot be encoded as {}. Convert the file to UTF-8 externally or use replacement text representable in that encoding.",
214
+ self.display_path(),
215
+ self.encoding_label()
216
+ )
217
+ })
218
+ }
219
+
220
+ pub(crate) fn raw_offset_cursor(&self) -> Result<RawOffsetCursor<'_>, String> {
221
+ let carriage_return_bytes = self.validated.encode_fragment("\r").ok_or_else(|| {
222
+ format!(
223
+ "Internal edit failure: source encoding {} cannot reproduce line endings.",
224
+ self.encoding_label()
225
+ )
226
+ })?;
227
+ Ok(RawOffsetCursor {
228
+ document: self,
229
+ logical_offset: 0,
230
+ raw_offset: self.validated.editable_raw_start(),
231
+ newline_index: 0,
232
+ carriage_return_bytes: carriage_return_bytes.len(),
233
+ })
234
+ }
235
+ }
236
+
237
+ pub(crate) struct RawOffsetCursor<'a> {
238
+ document: &'a TextDocument,
239
+ logical_offset: usize,
240
+ raw_offset: usize,
241
+ newline_index: usize,
242
+ carriage_return_bytes: usize,
243
+ }
244
+
245
+ impl RawOffsetCursor<'_> {
246
+ /// Advances monotonically through logical text while reconstructing original raw offsets.
247
+ pub(crate) fn advance_to(&mut self, target: usize) -> Result<usize, String> {
248
+ let logical = self.document.logical_text();
249
+ if target < self.logical_offset
250
+ || target > logical.len()
251
+ || !logical.is_char_boundary(target)
252
+ {
253
+ return Err(
254
+ "Internal edit failure: a logical range did not end on a character boundary."
255
+ .to_string(),
256
+ );
257
+ }
258
+ while self.logical_offset < target {
259
+ let end = next_char_boundary(logical, self.logical_offset, target, OFFSET_CHUNK_BYTES);
260
+ let chunk = &logical[self.logical_offset..end];
261
+ let encoded = self
262
+ .document
263
+ .validated
264
+ .encode_fragment(chunk)
265
+ .ok_or_else(|| {
266
+ format!(
267
+ "Internal edit failure: source encoding {} cannot reproduce unchanged text.",
268
+ self.document.encoding_label()
269
+ )
270
+ })?;
271
+ self.raw_offset = self.raw_offset.saturating_add(encoded.len());
272
+ for _ in chunk.bytes().filter(|byte| *byte == b'\n') {
273
+ if self
274
+ .document
275
+ .source_line_endings
276
+ .is_crlf(self.newline_index)
277
+ {
278
+ self.raw_offset = self.raw_offset.saturating_add(self.carriage_return_bytes);
279
+ }
280
+ self.newline_index = self.newline_index.saturating_add(1);
281
+ }
282
+ self.logical_offset = end;
283
+ }
284
+ Ok(self.raw_offset)
285
+ }
286
+ }
287
+
288
+ #[cfg(test)]
289
+ pub(crate) fn set_before_commit_hook(hook: impl FnOnce(&Path) + 'static) {
290
+ BEFORE_COMMIT.with(|slot| *slot.borrow_mut() = Some(Box::new(hook)));
291
+ }
292
+
293
+ #[cfg(test)]
294
+ fn run_before_commit_hook(path: &Path) {
295
+ BEFORE_COMMIT.with(|hook| {
296
+ if let Some(hook) = hook.borrow_mut().take() {
297
+ hook(path);
298
+ }
299
+ });
300
+ }
301
+
302
+ fn logical_view(text: String) -> LogicalView {
303
+ let mut bytes = text.into_bytes();
304
+ let mut source_line_endings = SourceLineEndings::default();
305
+ let mut crlf = 0_usize;
306
+ let mut lf = 0_usize;
307
+ let mut read = 0_usize;
308
+ let mut write = 0_usize;
309
+ while read < bytes.len() {
310
+ if bytes[read] == b'\r' && bytes.get(read + 1) == Some(&b'\n') {
311
+ bytes[write] = b'\n';
312
+ write += 1;
313
+ read += 2;
314
+ source_line_endings.push(true);
315
+ crlf += 1;
316
+ continue;
317
+ }
318
+ let byte = bytes[read];
319
+ bytes[write] = byte;
320
+ write += 1;
321
+ read += 1;
322
+ if byte == b'\n' {
323
+ source_line_endings.push(false);
324
+ lf += 1;
325
+ }
326
+ }
327
+ bytes.truncate(write);
328
+ let logical = String::from_utf8(bytes).expect("removing ASCII CR bytes preserves UTF-8");
329
+ let eol = if crlf > lf {
330
+ EolStyle::Crlf
331
+ } else {
332
+ EolStyle::Lf
333
+ };
334
+ LogicalView {
335
+ logical,
336
+ source_line_endings,
337
+ eol,
338
+ }
339
+ }
340
+
341
+ fn next_char_boundary(text: &str, start: usize, target: usize, maximum_bytes: usize) -> usize {
342
+ let mut end = start.saturating_add(maximum_bytes).min(target);
343
+ while end > start && !text.is_char_boundary(end) {
344
+ end -= 1;
345
+ }
346
+ if end == start { target } else { end }
347
+ }
348
+
349
+ enum LimitedRead {
350
+ Bytes(Vec<u8>),
351
+ TooLarge(u64),
352
+ }
353
+
354
+ fn read_limited(path: &Path, maximum_bytes: u64) -> Result<LimitedRead, String> {
355
+ let mut file = fs::File::open(path).map_err(|error| io_error_message(path, &error))?;
356
+ let initial_len = file
357
+ .metadata()
358
+ .map_err(|error| io_error_message(path, &error))?
359
+ .len();
360
+ if initial_len > maximum_bytes {
361
+ return Ok(LimitedRead::TooLarge(initial_len));
362
+ }
363
+ let capacity = usize::try_from(initial_len).unwrap_or(usize::MAX);
364
+ let mut bytes = Vec::with_capacity(capacity);
365
+ let mut buffer = [0_u8; OFFSET_CHUNK_BYTES];
366
+ loop {
367
+ let count = file
368
+ .read(&mut buffer)
369
+ .map_err(|error| io_error_message(path, &error))?;
370
+ if count == 0 {
371
+ return Ok(LimitedRead::Bytes(bytes));
372
+ }
373
+ let projected = bytes.len().saturating_add(count);
374
+ if u64::try_from(projected).unwrap_or(u64::MAX) > maximum_bytes {
375
+ let observed = file
376
+ .metadata()
377
+ .map(|metadata| metadata.len())
378
+ .unwrap_or(maximum_bytes.saturating_add(1))
379
+ .max(u64::try_from(projected).unwrap_or(u64::MAX));
380
+ return Ok(LimitedRead::TooLarge(observed));
381
+ }
382
+ bytes.extend_from_slice(&buffer[..count]);
383
+ }
384
+ }
385
+
386
+ fn file_matches_bytes(path: &Path, expected: &[u8]) -> Result<bool, String> {
387
+ let mut file = fs::File::open(path).map_err(|error| io_error_message(path, &error))?;
388
+ if file
389
+ .metadata()
390
+ .map_err(|error| io_error_message(path, &error))?
391
+ .len()
392
+ != u64::try_from(expected.len()).unwrap_or(u64::MAX)
393
+ {
394
+ return Ok(false);
395
+ }
396
+ let mut offset = 0_usize;
397
+ let mut buffer = [0_u8; OFFSET_CHUNK_BYTES];
398
+ loop {
399
+ let count = file
400
+ .read(&mut buffer)
401
+ .map_err(|error| io_error_message(path, &error))?;
402
+ if count == 0 {
403
+ return Ok(offset == expected.len());
404
+ }
405
+ let end = offset.saturating_add(count);
406
+ if expected.get(offset..end) != Some(&buffer[..count]) {
407
+ return Ok(false);
408
+ }
409
+ offset = end;
410
+ }
411
+ }
412
+
413
+ fn file_too_large_message(path: &Path, actual_bytes: u64, maximum_mib: u64) -> String {
414
+ format!(
415
+ "File too large for line edits: {} is {:.1} MiB (limit: {maximum_mib} MiB).",
416
+ display_path(path),
417
+ actual_bytes as f64 / 1_048_576.0
418
+ )
419
+ }
420
+
421
+ fn resolve_target(requested: &Path) -> Result<PathBuf, String> {
422
+ let metadata =
423
+ fs::symlink_metadata(requested).map_err(|error| io_error_message(requested, &error))?;
424
+ if metadata.file_type().is_symlink() {
425
+ let target = crate::paths::canonical_existing(requested).map_err(|_| {
426
+ format!(
427
+ "Cannot edit {}: it is a symbolic link that does not resolve to a file.",
428
+ display_path(requested)
429
+ )
430
+ })?;
431
+ if !target.is_file() {
432
+ return Err(format!(
433
+ "Cannot edit {}: it is a symbolic link that does not resolve to a file.",
434
+ display_path(requested)
435
+ ));
436
+ }
437
+ Ok(target)
438
+ } else {
439
+ Ok(crate::paths::canonical_existing(requested).unwrap_or_else(|_| requested.to_path_buf()))
440
+ }
441
+ }
442
+
443
+ fn reject_hard_link(path: &Path) -> Result<(), String> {
444
+ if hard_link_count(path)? > 1 {
445
+ return Err(format!(
446
+ "Cannot safely edit {}: it has multiple hard links, and an atomic replace would break them. Duplicate the file to a new path, or remove the extra links first.",
447
+ display_path(path)
448
+ ));
449
+ }
450
+ Ok(())
451
+ }
452
+
453
+ #[cfg(unix)]
454
+ fn hard_link_count(path: &Path) -> Result<u64, String> {
455
+ use std::os::unix::fs::MetadataExt;
456
+ fs::metadata(path)
457
+ .map(|metadata| metadata.nlink())
458
+ .map_err(|error| io_error_message(path, &error))
459
+ }
460
+
461
+ #[cfg(windows)]
462
+ fn hard_link_count(path: &Path) -> Result<u64, String> {
463
+ use std::fs::File;
464
+ use std::mem::MaybeUninit;
465
+ use std::os::windows::io::AsRawHandle;
466
+ use windows_sys::Win32::Storage::FileSystem::{
467
+ BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
468
+ };
469
+
470
+ let file = File::open(path).map_err(|error| io_error_message(path, &error))?;
471
+ let mut info = MaybeUninit::<BY_HANDLE_FILE_INFORMATION>::zeroed();
472
+ let success =
473
+ unsafe { GetFileInformationByHandle(file.as_raw_handle() as _, info.as_mut_ptr()) };
474
+ if success == 0 {
475
+ return Err(io_error_message(path, &std::io::Error::last_os_error()));
476
+ }
477
+ Ok(unsafe { info.assume_init() }.nNumberOfLinks as u64)
478
+ }
479
+
480
+ #[cfg(not(any(unix, windows)))]
481
+ fn hard_link_count(_path: &Path) -> Result<u64, String> {
482
+ Ok(1)
483
+ }
484
+
485
+ fn concurrent_message(path: &Path) -> String {
486
+ format!(
487
+ "{} changed on disk during the edit; nothing was written. Re-read it and retry.",
488
+ display_path(path)
489
+ )
490
+ }
491
+
492
+ #[cfg(test)]
493
+ mod tests {
494
+ use super::TextDocument;
495
+
496
+ #[test]
497
+ fn utf16_mapping_preserves_every_unmodified_raw_byte() {
498
+ let temp = tempfile::tempdir().unwrap();
499
+ let path = temp.path().join("utf16.txt");
500
+ let mut raw = vec![0xff, 0xfe];
501
+ for unit in "one\r\ntwo".encode_utf16() {
502
+ raw.extend(unit.to_le_bytes());
503
+ }
504
+ std::fs::write(&path, &raw).unwrap();
505
+ let document = TextDocument::open(path.to_str().unwrap(), None, 256).unwrap();
506
+ let start = document.logical_text().find("two").unwrap();
507
+ let end = start + "two".len();
508
+ let mut cursor = document.raw_offset_cursor().unwrap();
509
+ let raw_start = cursor.advance_to(start).unwrap();
510
+ let raw_end = cursor.advance_to(end).unwrap();
511
+ let mut result = Vec::new();
512
+ result.extend_from_slice(&document.original_bytes()[..raw_start]);
513
+ result.extend_from_slice(&document.encode_for_target("TWO").unwrap());
514
+ result.extend_from_slice(&document.original_bytes()[raw_end..]);
515
+ assert_eq!(&result[..12], &raw[..12]);
516
+ assert_eq!(&result[..2], &[0xff, 0xfe]);
517
+ }
518
+
519
+ #[cfg(unix)]
520
+ #[test]
521
+ fn dangling_symlink_uses_the_specific_recovery_error() {
522
+ use std::os::unix::fs::symlink;
523
+ let temp = tempfile::tempdir().unwrap();
524
+ let link = temp.path().join("dangling.txt");
525
+ symlink(temp.path().join("missing.txt"), &link).unwrap();
526
+ let error = TextDocument::open(link.to_str().unwrap(), None, 256).unwrap_err();
527
+ assert_eq!(
528
+ error,
529
+ format!(
530
+ "Cannot edit {}: it is a symbolic link that does not resolve to a file.",
531
+ crate::paths::display_path(&link)
532
+ )
533
+ );
534
+ }
535
+ }