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,531 @@
|
|
|
1
|
+
// Modified by dsh-ops (https://github.com/T-Auto/dsh-ops): removed the delete action and the running-binary fallback that only Apply/Unapply/self-update used, keeping write transactions as the server uses them.
|
|
2
|
+
//! Atomic file writes, conflict detection, and in-memory rollback.
|
|
3
|
+
|
|
4
|
+
use std::fs::{self, OpenOptions};
|
|
5
|
+
use std::io::{self, Write};
|
|
6
|
+
use std::path::{Path, PathBuf};
|
|
7
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
8
|
+
|
|
9
|
+
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
|
10
|
+
|
|
11
|
+
/// Desired state of one transaction target.
|
|
12
|
+
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
13
|
+
pub enum FileAction {
|
|
14
|
+
/// Replace or create the target with the supplied bytes.
|
|
15
|
+
Write(Vec<u8>),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// One file change frozen during preview.
|
|
19
|
+
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
20
|
+
pub struct FileChange {
|
|
21
|
+
/// Absolute target path.
|
|
22
|
+
pub target: PathBuf,
|
|
23
|
+
/// Original bytes read during preview, or `None` when the target was absent.
|
|
24
|
+
pub original: Option<Vec<u8>>,
|
|
25
|
+
/// New state to commit after confirmation.
|
|
26
|
+
pub action: FileAction,
|
|
27
|
+
/// Unix mode for a new file; existing files normally retain their original mode.
|
|
28
|
+
pub unix_mode: Option<u32>,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
impl FileChange {
|
|
32
|
+
/// Returns whether this target would actually change.
|
|
33
|
+
pub fn is_changed(&self) -> bool {
|
|
34
|
+
match &self.action {
|
|
35
|
+
FileAction::Write(bytes) => self.original.as_deref() != Some(bytes.as_slice()),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Reads a target snapshot and rejects targets whose symlink entry would be replaced.
|
|
41
|
+
pub fn read_snapshot(path: &Path) -> Result<Option<Vec<u8>>, String> {
|
|
42
|
+
let metadata = match fs::symlink_metadata(path) {
|
|
43
|
+
Ok(metadata) => metadata,
|
|
44
|
+
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
|
|
45
|
+
Err(error) => {
|
|
46
|
+
return Err(format!(
|
|
47
|
+
"Cannot inspect {}: {error}",
|
|
48
|
+
crate::paths::display_path(path)
|
|
49
|
+
));
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
if metadata.file_type().is_symlink() {
|
|
53
|
+
return Err(format!(
|
|
54
|
+
"Refusing to replace symbolic link {}. Point fastctx at a regular file or update it manually.",
|
|
55
|
+
crate::paths::display_path(path)
|
|
56
|
+
));
|
|
57
|
+
}
|
|
58
|
+
if !metadata.is_file() {
|
|
59
|
+
return Err(format!(
|
|
60
|
+
"Cannot replace {} because it is not a regular file.",
|
|
61
|
+
crate::paths::display_path(path)
|
|
62
|
+
));
|
|
63
|
+
}
|
|
64
|
+
fs::read(path)
|
|
65
|
+
.map(Some)
|
|
66
|
+
.map_err(|error| format!("Cannot read {}: {error}", crate::paths::display_path(path)))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// Returns the Unix mode of an existing file, or `None` on other platforms.
|
|
70
|
+
pub fn existing_unix_mode(path: &Path) -> Option<u32> {
|
|
71
|
+
#[cfg(unix)]
|
|
72
|
+
{
|
|
73
|
+
use std::os::unix::fs::PermissionsExt;
|
|
74
|
+
fs::metadata(path)
|
|
75
|
+
.ok()
|
|
76
|
+
.map(|metadata| metadata.permissions().mode())
|
|
77
|
+
}
|
|
78
|
+
#[cfg(not(unix))]
|
|
79
|
+
{
|
|
80
|
+
let _ = path;
|
|
81
|
+
None
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// Commits every change produced by the same immutable preview.
|
|
86
|
+
pub fn commit(changes: &[FileChange]) -> Result<(), String> {
|
|
87
|
+
commit_with_hook(changes, |_| {})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fn commit_with_hook(
|
|
91
|
+
changes: &[FileChange],
|
|
92
|
+
mut before_apply: impl FnMut(usize),
|
|
93
|
+
) -> Result<(), String> {
|
|
94
|
+
let changes = changes
|
|
95
|
+
.iter()
|
|
96
|
+
.filter(|change| change.is_changed())
|
|
97
|
+
.collect::<Vec<_>>();
|
|
98
|
+
if changes.is_empty() {
|
|
99
|
+
return Ok(());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
validate_changes(&changes)?;
|
|
103
|
+
|
|
104
|
+
let mut created_directories = Vec::new();
|
|
105
|
+
for change in &changes {
|
|
106
|
+
let parent = change.target.parent().ok_or_else(|| {
|
|
107
|
+
format!(
|
|
108
|
+
"Cannot determine the parent directory for {}.",
|
|
109
|
+
crate::paths::display_path(&change.target)
|
|
110
|
+
)
|
|
111
|
+
})?;
|
|
112
|
+
create_parent_chain(parent, &mut created_directories)?;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// There are no file-level backups: same-directory atomic replacement is rolled back from
|
|
116
|
+
// change.original in memory after a mid-transaction failure (2026-07-12).
|
|
117
|
+
let mut committed: Vec<&FileChange> = Vec::new();
|
|
118
|
+
for (index, change) in changes.iter().enumerate() {
|
|
119
|
+
before_apply(index);
|
|
120
|
+
let result = read_snapshot(&change.target).and_then(|current| {
|
|
121
|
+
if current != change.original {
|
|
122
|
+
Err(format!(
|
|
123
|
+
"{} changed while the transaction was in progress. The pending write was refused.",
|
|
124
|
+
crate::paths::display_path(&change.target)
|
|
125
|
+
))
|
|
126
|
+
} else {
|
|
127
|
+
apply_change(change)
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
if let Err(error) = result {
|
|
131
|
+
let mut rollback_errors = Vec::new();
|
|
132
|
+
for applied in committed.iter().rev() {
|
|
133
|
+
if let Err(rollback_error) = restore_change(applied) {
|
|
134
|
+
rollback_errors.push(rollback_error);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
cleanup_directories(&created_directories);
|
|
138
|
+
if rollback_errors.is_empty() {
|
|
139
|
+
return Err(format!(
|
|
140
|
+
"Cannot update {}: {error}. Earlier writes were rolled back.",
|
|
141
|
+
crate::paths::display_path(&change.target)
|
|
142
|
+
));
|
|
143
|
+
}
|
|
144
|
+
return Err(format!(
|
|
145
|
+
"Cannot update {}: {error}. Rollback also failed: {}.",
|
|
146
|
+
crate::paths::display_path(&change.target),
|
|
147
|
+
rollback_errors.join("; ")
|
|
148
|
+
));
|
|
149
|
+
}
|
|
150
|
+
committed.push(*change);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
Ok(())
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
fn validate_changes(changes: &[&FileChange]) -> Result<(), String> {
|
|
157
|
+
for change in changes {
|
|
158
|
+
let current = read_snapshot(&change.target)?;
|
|
159
|
+
if current != change.original {
|
|
160
|
+
return Err(format!(
|
|
161
|
+
"{} changed after the preview. No files were written; preview again and retry.",
|
|
162
|
+
crate::paths::display_path(&change.target)
|
|
163
|
+
));
|
|
164
|
+
}
|
|
165
|
+
if current.is_some() {
|
|
166
|
+
ensure_target_writable(&change.target)?;
|
|
167
|
+
}
|
|
168
|
+
let parent = change.target.parent().ok_or_else(|| {
|
|
169
|
+
format!(
|
|
170
|
+
"Cannot determine the parent directory for {}.",
|
|
171
|
+
crate::paths::display_path(&change.target)
|
|
172
|
+
)
|
|
173
|
+
})?;
|
|
174
|
+
validate_parent_chain(parent)?;
|
|
175
|
+
}
|
|
176
|
+
Ok(())
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
fn validate_parent_chain(path: &Path) -> Result<(), String> {
|
|
180
|
+
let mut cursor = path;
|
|
181
|
+
while !cursor.exists() {
|
|
182
|
+
cursor = cursor.parent().ok_or_else(|| {
|
|
183
|
+
format!(
|
|
184
|
+
"Cannot find an existing parent for {}.",
|
|
185
|
+
crate::paths::display_path(path)
|
|
186
|
+
)
|
|
187
|
+
})?;
|
|
188
|
+
}
|
|
189
|
+
if !cursor.is_dir() {
|
|
190
|
+
return Err(format!(
|
|
191
|
+
"Cannot create files under {} because an ancestor is not a directory.",
|
|
192
|
+
crate::paths::display_path(path)
|
|
193
|
+
));
|
|
194
|
+
}
|
|
195
|
+
Ok(())
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
fn ensure_target_writable(path: &Path) -> Result<(), String> {
|
|
199
|
+
let metadata = fs::metadata(path).map_err(|error| {
|
|
200
|
+
format!(
|
|
201
|
+
"Cannot inspect permissions for {}: {error}",
|
|
202
|
+
crate::paths::display_path(path)
|
|
203
|
+
)
|
|
204
|
+
})?;
|
|
205
|
+
if metadata.permissions().readonly() {
|
|
206
|
+
return Err(format!(
|
|
207
|
+
"Cannot update read-only file {}. Make it writable and retry; no files were written.",
|
|
208
|
+
crate::paths::display_path(path)
|
|
209
|
+
));
|
|
210
|
+
}
|
|
211
|
+
Ok(())
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
fn apply_change(change: &FileChange) -> Result<(), String> {
|
|
215
|
+
match &change.action {
|
|
216
|
+
FileAction::Write(bytes) => atomic_replace(&change.target, bytes, change.unix_mode),
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
fn restore_change(change: &FileChange) -> Result<(), String> {
|
|
221
|
+
let current = read_snapshot(&change.target)?;
|
|
222
|
+
let expected = match &change.action {
|
|
223
|
+
FileAction::Write(bytes) => Some(bytes.as_slice()),
|
|
224
|
+
};
|
|
225
|
+
if current.as_deref() != expected {
|
|
226
|
+
return Err(format!(
|
|
227
|
+
"refusing to roll back {} because it changed after fastctx wrote it",
|
|
228
|
+
crate::paths::display_path(&change.target)
|
|
229
|
+
));
|
|
230
|
+
}
|
|
231
|
+
match &change.original {
|
|
232
|
+
Some(bytes) => atomic_replace(&change.target, bytes, change.unix_mode),
|
|
233
|
+
None => match fs::remove_file(&change.target) {
|
|
234
|
+
Ok(()) => Ok(()),
|
|
235
|
+
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
|
236
|
+
Err(error) => Err(format!(
|
|
237
|
+
"cannot remove newly-created {}: {error}",
|
|
238
|
+
crate::paths::display_path(&change.target)
|
|
239
|
+
)),
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/// Writes a temporary file beside the target and atomically replaces the target.
|
|
245
|
+
pub fn atomic_replace(target: &Path, bytes: &[u8], unix_mode: Option<u32>) -> Result<(), String> {
|
|
246
|
+
let parent = target.parent().ok_or_else(|| {
|
|
247
|
+
format!(
|
|
248
|
+
"Cannot determine the parent directory for {}.",
|
|
249
|
+
crate::paths::display_path(target)
|
|
250
|
+
)
|
|
251
|
+
})?;
|
|
252
|
+
fs::create_dir_all(parent).map_err(|error| {
|
|
253
|
+
format!(
|
|
254
|
+
"Cannot create {}: {error}",
|
|
255
|
+
crate::paths::display_path(parent)
|
|
256
|
+
)
|
|
257
|
+
})?;
|
|
258
|
+
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
|
259
|
+
let filename = target
|
|
260
|
+
.file_name()
|
|
261
|
+
.and_then(|value| value.to_str())
|
|
262
|
+
.unwrap_or("fastctx");
|
|
263
|
+
let temporary = parent.join(format!(
|
|
264
|
+
".{filename}.{}.{}.tmp",
|
|
265
|
+
std::process::id(),
|
|
266
|
+
sequence
|
|
267
|
+
));
|
|
268
|
+
let write_result = write_temporary(&temporary, bytes, unix_mode);
|
|
269
|
+
if let Err(error) = write_result {
|
|
270
|
+
let _ = fs::remove_file(&temporary);
|
|
271
|
+
return Err(error);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let replace_result = replace_path(&temporary, target);
|
|
275
|
+
if let Err(error) = replace_result {
|
|
276
|
+
let _ = fs::remove_file(&temporary);
|
|
277
|
+
return Err(error);
|
|
278
|
+
}
|
|
279
|
+
Ok(())
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
fn write_temporary(path: &Path, bytes: &[u8], unix_mode: Option<u32>) -> Result<(), String> {
|
|
283
|
+
#[cfg(not(unix))]
|
|
284
|
+
let _ = unix_mode;
|
|
285
|
+
let mut options = OpenOptions::new();
|
|
286
|
+
options.create_new(true).write(true);
|
|
287
|
+
#[cfg(unix)]
|
|
288
|
+
{
|
|
289
|
+
use std::os::unix::fs::OpenOptionsExt;
|
|
290
|
+
options.mode(unix_mode.unwrap_or(0o600));
|
|
291
|
+
}
|
|
292
|
+
let mut file = options.open(path).map_err(|error| {
|
|
293
|
+
format!(
|
|
294
|
+
"Cannot create temporary file {}: {error}",
|
|
295
|
+
crate::paths::display_path(path)
|
|
296
|
+
)
|
|
297
|
+
})?;
|
|
298
|
+
#[cfg(unix)]
|
|
299
|
+
if let Some(mode) = unix_mode {
|
|
300
|
+
use std::os::unix::fs::PermissionsExt;
|
|
301
|
+
// OpenOptionsExt::mode is filtered by umask; restore the exact existing mode before publish.
|
|
302
|
+
file.set_permissions(fs::Permissions::from_mode(mode))
|
|
303
|
+
.map_err(|error| {
|
|
304
|
+
format!(
|
|
305
|
+
"Cannot preserve permissions on temporary file {}: {error}",
|
|
306
|
+
crate::paths::display_path(path)
|
|
307
|
+
)
|
|
308
|
+
})?;
|
|
309
|
+
}
|
|
310
|
+
file.write_all(bytes).map_err(|error| {
|
|
311
|
+
format!(
|
|
312
|
+
"Cannot write temporary file {}: {error}",
|
|
313
|
+
crate::paths::display_path(path)
|
|
314
|
+
)
|
|
315
|
+
})?;
|
|
316
|
+
file.sync_all().map_err(|error| {
|
|
317
|
+
format!(
|
|
318
|
+
"Cannot sync temporary file {}: {error}",
|
|
319
|
+
crate::paths::display_path(path)
|
|
320
|
+
)
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
fn create_parent_chain(path: &Path, created: &mut Vec<PathBuf>) -> Result<(), String> {
|
|
325
|
+
if path.exists() {
|
|
326
|
+
if path.is_dir() {
|
|
327
|
+
return Ok(());
|
|
328
|
+
}
|
|
329
|
+
return Err(format!(
|
|
330
|
+
"Cannot create files under {} because it is not a directory.",
|
|
331
|
+
crate::paths::display_path(path)
|
|
332
|
+
));
|
|
333
|
+
}
|
|
334
|
+
let mut missing = Vec::new();
|
|
335
|
+
let mut cursor = path;
|
|
336
|
+
while !cursor.exists() {
|
|
337
|
+
missing.push(cursor.to_path_buf());
|
|
338
|
+
cursor = cursor.parent().ok_or_else(|| {
|
|
339
|
+
format!(
|
|
340
|
+
"Cannot find an existing parent for {}.",
|
|
341
|
+
crate::paths::display_path(path)
|
|
342
|
+
)
|
|
343
|
+
})?;
|
|
344
|
+
}
|
|
345
|
+
if !cursor.is_dir() {
|
|
346
|
+
return Err(format!(
|
|
347
|
+
"Cannot create files under {} because an ancestor is not a directory.",
|
|
348
|
+
crate::paths::display_path(path)
|
|
349
|
+
));
|
|
350
|
+
}
|
|
351
|
+
for directory in missing.iter().rev() {
|
|
352
|
+
fs::create_dir(directory).map_err(|error| {
|
|
353
|
+
format!(
|
|
354
|
+
"Cannot create directory {}: {error}",
|
|
355
|
+
crate::paths::display_path(directory)
|
|
356
|
+
)
|
|
357
|
+
})?;
|
|
358
|
+
created.push(directory.clone());
|
|
359
|
+
}
|
|
360
|
+
Ok(())
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
fn cleanup_directories(created: &[PathBuf]) {
|
|
364
|
+
for directory in created.iter().rev() {
|
|
365
|
+
let _ = fs::remove_dir(directory);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
#[cfg(unix)]
|
|
370
|
+
fn replace_path(temporary: &Path, target: &Path) -> Result<(), String> {
|
|
371
|
+
fs::rename(temporary, target).map_err(|error| {
|
|
372
|
+
format!(
|
|
373
|
+
"Cannot atomically replace {}: {error}",
|
|
374
|
+
crate::paths::display_path(target)
|
|
375
|
+
)
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
#[cfg(windows)]
|
|
380
|
+
fn replace_path(temporary: &Path, target: &Path) -> Result<(), String> {
|
|
381
|
+
use std::os::windows::ffi::OsStrExt;
|
|
382
|
+
use windows_sys::Win32::Storage::FileSystem::{
|
|
383
|
+
MOVEFILE_WRITE_THROUGH, MoveFileExW, REPLACEFILE_WRITE_THROUGH, ReplaceFileW,
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
fn wide(path: &Path) -> Vec<u16> {
|
|
387
|
+
path.as_os_str().encode_wide().chain(Some(0)).collect()
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
let temporary_wide = wide(temporary);
|
|
391
|
+
let target_wide = wide(target);
|
|
392
|
+
if !target.exists() {
|
|
393
|
+
let moved = unsafe {
|
|
394
|
+
MoveFileExW(
|
|
395
|
+
temporary_wide.as_ptr(),
|
|
396
|
+
target_wide.as_ptr(),
|
|
397
|
+
MOVEFILE_WRITE_THROUGH,
|
|
398
|
+
)
|
|
399
|
+
};
|
|
400
|
+
if moved != 0 {
|
|
401
|
+
return Ok(());
|
|
402
|
+
}
|
|
403
|
+
return Err(format!(
|
|
404
|
+
"Cannot atomically create {}: {}",
|
|
405
|
+
crate::paths::display_path(target),
|
|
406
|
+
io::Error::last_os_error()
|
|
407
|
+
));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
let replaced = unsafe {
|
|
411
|
+
ReplaceFileW(
|
|
412
|
+
target_wide.as_ptr(),
|
|
413
|
+
temporary_wide.as_ptr(),
|
|
414
|
+
std::ptr::null(),
|
|
415
|
+
REPLACEFILE_WRITE_THROUGH,
|
|
416
|
+
std::ptr::null_mut(),
|
|
417
|
+
std::ptr::null_mut(),
|
|
418
|
+
)
|
|
419
|
+
};
|
|
420
|
+
if replaced != 0 {
|
|
421
|
+
return Ok(());
|
|
422
|
+
}
|
|
423
|
+
Err(format!(
|
|
424
|
+
"Cannot atomically replace {}: {}",
|
|
425
|
+
crate::paths::display_path(target),
|
|
426
|
+
io::Error::last_os_error()
|
|
427
|
+
))
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
#[cfg(test)]
|
|
431
|
+
mod tests {
|
|
432
|
+
#[cfg(windows)]
|
|
433
|
+
use super::atomic_replace;
|
|
434
|
+
use super::{FileAction, FileChange, commit, commit_with_hook, read_snapshot};
|
|
435
|
+
|
|
436
|
+
#[test]
|
|
437
|
+
fn a_conflict_after_preview_refuses_every_write() {
|
|
438
|
+
let temp = tempfile::tempdir().unwrap();
|
|
439
|
+
let first = temp.path().join("first.txt");
|
|
440
|
+
let second = temp.path().join("second.txt");
|
|
441
|
+
std::fs::write(&first, b"one").unwrap();
|
|
442
|
+
std::fs::write(&second, b"two").unwrap();
|
|
443
|
+
let first_original = read_snapshot(&first).unwrap();
|
|
444
|
+
let second_original = read_snapshot(&second).unwrap();
|
|
445
|
+
let changes = vec![
|
|
446
|
+
FileChange {
|
|
447
|
+
target: first.clone(),
|
|
448
|
+
original: first_original,
|
|
449
|
+
action: FileAction::Write(b"ONE".to_vec()),
|
|
450
|
+
unix_mode: None,
|
|
451
|
+
},
|
|
452
|
+
FileChange {
|
|
453
|
+
target: second.clone(),
|
|
454
|
+
original: second_original,
|
|
455
|
+
action: FileAction::Write(b"TWO".to_vec()),
|
|
456
|
+
unix_mode: None,
|
|
457
|
+
},
|
|
458
|
+
];
|
|
459
|
+
std::fs::write(&second, b"user edit").unwrap();
|
|
460
|
+
|
|
461
|
+
let error = commit(&changes).unwrap_err();
|
|
462
|
+
assert!(error.contains("changed after the preview"));
|
|
463
|
+
assert_eq!(std::fs::read(&first).unwrap(), b"one");
|
|
464
|
+
assert_eq!(std::fs::read(&second).unwrap(), b"user edit");
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
#[test]
|
|
468
|
+
fn a_change_between_two_writes_is_detected_and_the_first_write_rolls_back() {
|
|
469
|
+
let temp = tempfile::tempdir().unwrap();
|
|
470
|
+
let first = temp.path().join("first.txt");
|
|
471
|
+
let second = temp.path().join("second.txt");
|
|
472
|
+
std::fs::write(&first, b"one").unwrap();
|
|
473
|
+
std::fs::write(&second, b"two").unwrap();
|
|
474
|
+
let changes = vec![
|
|
475
|
+
FileChange {
|
|
476
|
+
target: first.clone(),
|
|
477
|
+
original: read_snapshot(&first).unwrap(),
|
|
478
|
+
action: FileAction::Write(b"ONE".to_vec()),
|
|
479
|
+
unix_mode: None,
|
|
480
|
+
},
|
|
481
|
+
FileChange {
|
|
482
|
+
target: second.clone(),
|
|
483
|
+
original: read_snapshot(&second).unwrap(),
|
|
484
|
+
action: FileAction::Write(b"TWO".to_vec()),
|
|
485
|
+
unix_mode: None,
|
|
486
|
+
},
|
|
487
|
+
];
|
|
488
|
+
|
|
489
|
+
let error = commit_with_hook(&changes, |index| {
|
|
490
|
+
if index == 1 {
|
|
491
|
+
std::fs::write(&second, b"user edit during commit").unwrap();
|
|
492
|
+
}
|
|
493
|
+
})
|
|
494
|
+
.unwrap_err();
|
|
495
|
+
assert!(error.contains("changed while the transaction was in progress"));
|
|
496
|
+
assert_eq!(std::fs::read(&first).unwrap(), b"one");
|
|
497
|
+
assert_eq!(std::fs::read(&second).unwrap(), b"user edit during commit");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
#[cfg(windows)]
|
|
501
|
+
#[test]
|
|
502
|
+
fn replace_file_w_preserves_existing_windows_file_attributes() {
|
|
503
|
+
use std::os::windows::ffi::OsStrExt;
|
|
504
|
+
use windows_sys::Win32::Storage::FileSystem::{
|
|
505
|
+
FILE_ATTRIBUTE_HIDDEN, GetFileAttributesW, INVALID_FILE_ATTRIBUTES, SetFileAttributesW,
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
let temp = tempfile::tempdir().unwrap();
|
|
509
|
+
let path = temp.path().join("hidden.txt");
|
|
510
|
+
std::fs::write(&path, b"old").unwrap();
|
|
511
|
+
let wide = path
|
|
512
|
+
.as_os_str()
|
|
513
|
+
.encode_wide()
|
|
514
|
+
.chain(Some(0))
|
|
515
|
+
.collect::<Vec<_>>();
|
|
516
|
+
let original = unsafe { GetFileAttributesW(wide.as_ptr()) };
|
|
517
|
+
assert_ne!(original, INVALID_FILE_ATTRIBUTES);
|
|
518
|
+
assert_ne!(
|
|
519
|
+
unsafe { SetFileAttributesW(wide.as_ptr(), original | FILE_ATTRIBUTE_HIDDEN) },
|
|
520
|
+
0
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
atomic_replace(&path, b"new", None).unwrap();
|
|
524
|
+
let replaced = unsafe { GetFileAttributesW(wide.as_ptr()) };
|
|
525
|
+
assert_ne!(replaced, INVALID_FILE_ATTRIBUTES);
|
|
526
|
+
assert_ne!(replaced & FILE_ATTRIBUTE_HIDDEN, 0);
|
|
527
|
+
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
|
528
|
+
|
|
529
|
+
assert_ne!(unsafe { SetFileAttributesW(wide.as_ptr(), original) }, 0);
|
|
530
|
+
}
|
|
531
|
+
}
|