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.
Files changed (140) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/LICENSE +30 -0
  3. package/NOTICE +106 -0
  4. package/PROVENANCE.md +435 -0
  5. package/README.en.md +126 -0
  6. package/README.md +115 -2
  7. package/README.zh.md +116 -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/release-0.2.1.md +72 -0
  12. package/docs/schema-baseline.json +64 -0
  13. package/docs/schema-current.json +84 -0
  14. package/docs/schema-measurement.md +17 -0
  15. package/dsh-plugin.json +88 -0
  16. package/icon.svg +12 -0
  17. package/lib/binary.js +409 -0
  18. package/lib/config.js +198 -0
  19. package/lib/handshake.js +252 -0
  20. package/lib/index.js +108 -0
  21. package/lib/jobs.js +42 -0
  22. package/lib/policy.js +64 -0
  23. package/lib/presentation.js +63 -0
  24. package/lib/profile-install.js +61 -0
  25. package/lib/rust.js +194 -0
  26. package/lib/session-shells.js +78 -0
  27. package/lib/shells.js +998 -0
  28. package/lib/tools.js +657 -0
  29. package/locale/en.json +6 -0
  30. package/locale/zh.json +6 -0
  31. package/package.json +114 -4
  32. package/vendor/fastctx/Cargo.lock +3210 -0
  33. package/vendor/fastctx/Cargo.toml +94 -0
  34. package/vendor/fastctx/FORK.md +119 -0
  35. package/vendor/fastctx/LICENSE-APACHE +201 -0
  36. package/vendor/fastctx/NOTICE +40 -0
  37. package/vendor/fastctx/README.md +439 -0
  38. package/vendor/fastctx/THIRD_PARTY_LICENSES.md +17 -0
  39. package/vendor/fastctx/THIRD_PARTY_LICENSES_RUST.md +7914 -0
  40. package/vendor/fastctx/UPSTREAM.md +49 -0
  41. package/vendor/fastctx/build.rs +413 -0
  42. package/vendor/fastctx/src/background_status.rs +403 -0
  43. package/vendor/fastctx/src/binary.rs +75 -0
  44. package/vendor/fastctx/src/bounded_sort.rs +500 -0
  45. package/vendor/fastctx/src/budget.rs +781 -0
  46. package/vendor/fastctx/src/cli/mod.rs +110 -0
  47. package/vendor/fastctx/src/context_guard.rs +289 -0
  48. package/vendor/fastctx/src/control/mod.rs +6 -0
  49. package/vendor/fastctx/src/control/paths.rs +49 -0
  50. package/vendor/fastctx/src/control/settings.rs +753 -0
  51. package/vendor/fastctx/src/control/transaction.rs +531 -0
  52. package/vendor/fastctx/src/edit/document.rs +535 -0
  53. package/vendor/fastctx/src/edit/locks.rs +371 -0
  54. package/vendor/fastctx/src/edit/mod.rs +213 -0
  55. package/vendor/fastctx/src/edit/private_storage/unix.rs +315 -0
  56. package/vendor/fastctx/src/edit/private_storage/windows.rs +793 -0
  57. package/vendor/fastctx/src/edit/private_storage.rs +234 -0
  58. package/vendor/fastctx/src/edit/replace.rs +1030 -0
  59. package/vendor/fastctx/src/edit_server.rs +53 -0
  60. package/vendor/fastctx/src/encoding/reference_v011.rs +587 -0
  61. package/vendor/fastctx/src/encoding/snapshot_pipeline.rs +1678 -0
  62. package/vendor/fastctx/src/encoding.rs +1118 -0
  63. package/vendor/fastctx/src/file_executor.rs +1151 -0
  64. package/vendor/fastctx/src/file_snapshot.rs +1491 -0
  65. package/vendor/fastctx/src/glob_filter.rs +98 -0
  66. package/vendor/fastctx/src/glob_tool.rs +653 -0
  67. package/vendor/fastctx/src/grep_sink.rs +1162 -0
  68. package/vendor/fastctx/src/grep_tool.rs +2449 -0
  69. package/vendor/fastctx/src/lib.rs +45 -0
  70. package/vendor/fastctx/src/main.rs +15 -0
  71. package/vendor/fastctx/src/model.rs +51 -0
  72. package/vendor/fastctx/src/model_guidance.rs +62 -0
  73. package/vendor/fastctx/src/operation.rs +356 -0
  74. package/vendor/fastctx/src/ordered_window.rs +1235 -0
  75. package/vendor/fastctx/src/os_environment.rs +414 -0
  76. package/vendor/fastctx/src/path_codec.rs +850 -0
  77. package/vendor/fastctx/src/paths.rs +244 -0
  78. package/vendor/fastctx/src/process_identity.rs +763 -0
  79. package/vendor/fastctx/src/process_policy.rs +74 -0
  80. package/vendor/fastctx/src/read_tool/batch.rs +496 -0
  81. package/vendor/fastctx/src/read_tool/hex_file.rs +141 -0
  82. package/vendor/fastctx/src/read_tool/image_file.rs +88 -0
  83. package/vendor/fastctx/src/read_tool/mod.rs +245 -0
  84. package/vendor/fastctx/src/read_tool/pdf.rs +470 -0
  85. package/vendor/fastctx/src/read_tool/pdf_disabled.rs +47 -0
  86. package/vendor/fastctx/src/read_tool/pdf_engine.rs +664 -0
  87. package/vendor/fastctx/src/read_tool/text_file.rs +351 -0
  88. package/vendor/fastctx/src/render_plan.rs +468 -0
  89. package/vendor/fastctx/src/runtime/activity.rs +159 -0
  90. package/vendor/fastctx/src/runtime/hosts.rs +99 -0
  91. package/vendor/fastctx/src/runtime/journal.rs +556 -0
  92. package/vendor/fastctx/src/runtime/local_ipc.rs +186 -0
  93. package/vendor/fastctx/src/runtime/mod.rs +746 -0
  94. package/vendor/fastctx/src/runtime/protocol.rs +296 -0
  95. package/vendor/fastctx/src/runtime/session.rs +536 -0
  96. package/vendor/fastctx/src/runtime/windows_process.rs +66 -0
  97. package/vendor/fastctx/src/search_parallelism.rs +106 -0
  98. package/vendor/fastctx/src/search_text.rs +227 -0
  99. package/vendor/fastctx/src/server.rs +359 -0
  100. package/vendor/fastctx/src/server_manifest.rs +468 -0
  101. package/vendor/fastctx/src/server_support.rs +826 -0
  102. package/vendor/fastctx/src/session.rs +629 -0
  103. package/vendor/fastctx/src/shell/apply_patch_hint.rs +41 -0
  104. package/vendor/fastctx/src/shell/bash.rs +263 -0
  105. package/vendor/fastctx/src/shell/buffer.rs +108 -0
  106. package/vendor/fastctx/src/shell/encoding.rs +403 -0
  107. package/vendor/fastctx/src/shell/foreground.rs +115 -0
  108. package/vendor/fastctx/src/shell/jobs/admission.rs +91 -0
  109. package/vendor/fastctx/src/shell/jobs/background.rs +146 -0
  110. package/vendor/fastctx/src/shell/jobs/host.rs +830 -0
  111. package/vendor/fastctx/src/shell/jobs/identity.rs +29 -0
  112. package/vendor/fastctx/src/shell/jobs/mod.rs +1513 -0
  113. package/vendor/fastctx/src/shell/jobs/model.rs +244 -0
  114. package/vendor/fastctx/src/shell/jobs/output_log.rs +1148 -0
  115. package/vendor/fastctx/src/shell/jobs/store.rs +1300 -0
  116. package/vendor/fastctx/src/shell/mod.rs +345 -0
  117. package/vendor/fastctx/src/shell/normalize.rs +389 -0
  118. package/vendor/fastctx/src/shell/output.rs +406 -0
  119. package/vendor/fastctx/src/shell/process.rs +493 -0
  120. package/vendor/fastctx/src/shell_server.rs +156 -0
  121. package/vendor/fastctx/src/skip_report.rs +83 -0
  122. package/vendor/fastctx/src/stdio_transport.rs +177 -0
  123. package/vendor/fastctx/src/tool_schema.rs +204 -0
  124. package/vendor/fastctx/src/traversal.rs +846 -0
  125. package/vendor/fastctx/third-party/pdfium-7763/LICENSE +9 -0
  126. package/vendor/fastctx/third-party/pdfium-7763/licenses/abseil.txt +202 -0
  127. package/vendor/fastctx/third-party/pdfium-7763/licenses/agg23.txt +14 -0
  128. package/vendor/fastctx/third-party/pdfium-7763/licenses/fast_float.txt +27 -0
  129. package/vendor/fastctx/third-party/pdfium-7763/licenses/freetype.txt +169 -0
  130. package/vendor/fastctx/third-party/pdfium-7763/licenses/icu.txt +542 -0
  131. package/vendor/fastctx/third-party/pdfium-7763/licenses/lcms.txt +27 -0
  132. package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.ijg +260 -0
  133. package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.md +135 -0
  134. package/vendor/fastctx/third-party/pdfium-7763/licenses/libopenjpeg.txt +32 -0
  135. package/vendor/fastctx/third-party/pdfium-7763/licenses/libpng.txt +134 -0
  136. package/vendor/fastctx/third-party/pdfium-7763/licenses/libtiff.txt +21 -0
  137. package/vendor/fastctx/third-party/pdfium-7763/licenses/llvm-libc.txt +278 -0
  138. package/vendor/fastctx/third-party/pdfium-7763/licenses/pdfium.txt +230 -0
  139. package/vendor/fastctx/third-party/pdfium-7763/licenses/simdutf.txt +18 -0
  140. package/vendor/fastctx/third-party/pdfium-7763/licenses/zlib.txt +29 -0
@@ -0,0 +1,664 @@
1
+ //! Validation, atomic extraction, and lazy binding for the embedded Pdfium library.
2
+
3
+ use fs2::FileExt;
4
+ use pdfium_render::prelude::Pdfium;
5
+ use sha2::{Digest, Sha256};
6
+ use std::fs::{self, File, OpenOptions};
7
+ use std::io::{Read, Write};
8
+ use std::panic::{AssertUnwindSafe, catch_unwind};
9
+ use std::path::{Path, PathBuf};
10
+ use std::sync::atomic::{AtomicU64, Ordering};
11
+ use std::sync::mpsc::{self, RecvTimeoutError};
12
+ use std::sync::{Mutex, MutexGuard, OnceLock};
13
+ use std::time::Duration;
14
+
15
+ mod bundled {
16
+ include!(concat!(env!("OUT_DIR"), "/pdfium_embedded.rs"));
17
+ }
18
+
19
+ #[derive(Clone, Copy)]
20
+ struct EngineArtifact<'a> {
21
+ bytes: &'a [u8],
22
+ filename: &'a str,
23
+ sha256: &'a str,
24
+ release_tag: &'a str,
25
+ }
26
+
27
+ const BUNDLED_ARTIFACT: EngineArtifact<'static> = EngineArtifact {
28
+ bytes: bundled::PDFIUM_BYTES,
29
+ filename: bundled::PDFIUM_FILENAME,
30
+ sha256: bundled::PDFIUM_SHA256,
31
+ release_tag: bundled::PDFIUM_RELEASE_TAG,
32
+ };
33
+
34
+ static PDFIUM: OnceLock<Pdfium> = OnceLock::new();
35
+ static PDFIUM_INITIALIZATION: Mutex<()> = Mutex::new(());
36
+ static PDFIUM_OPERATIONS: Mutex<()> = Mutex::new(());
37
+ static PDF_RUNTIME: PdfRuntime = PdfRuntime::new();
38
+ static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
39
+
40
+ const PDF_OPERATION_TIMEOUT: Duration = Duration::from_secs(60);
41
+
42
+ #[derive(Debug, Eq, PartialEq)]
43
+ pub(super) enum PdfOperationError {
44
+ TimedOut,
45
+ Unavailable(String),
46
+ }
47
+
48
+ struct PdfRuntime {
49
+ degraded_reason: OnceLock<String>,
50
+ }
51
+
52
+ impl PdfRuntime {
53
+ const fn new() -> Self {
54
+ Self {
55
+ degraded_reason: OnceLock::new(),
56
+ }
57
+ }
58
+
59
+ fn run<T: Send + 'static>(
60
+ &self,
61
+ timeout: Duration,
62
+ operation: impl FnOnce() -> T + Send + 'static,
63
+ ) -> Result<T, PdfOperationError> {
64
+ if let Some(reason) = self.degraded_reason() {
65
+ return Err(PdfOperationError::Unavailable(reason));
66
+ }
67
+
68
+ let (sender, receiver) = mpsc::sync_channel(1);
69
+ let environment = crate::session::active_environment();
70
+ std::thread::Builder::new()
71
+ .name("fastctx-pdf-operation".to_string())
72
+ .spawn(move || {
73
+ let outcome = match environment {
74
+ Some(environment) => {
75
+ environment.activate(|| catch_unwind(AssertUnwindSafe(operation)))
76
+ }
77
+ None => catch_unwind(AssertUnwindSafe(operation)),
78
+ };
79
+ let _ = sender.send(outcome);
80
+ })
81
+ .map_err(|error| {
82
+ PdfOperationError::Unavailable(format!(
83
+ "could not start the isolated PDF operation: {error}"
84
+ ))
85
+ })?;
86
+
87
+ match receiver.recv_timeout(timeout) {
88
+ Ok(Ok(response)) => Ok(response),
89
+ Ok(Err(_)) => Err(PdfOperationError::Unavailable(self.degrade(
90
+ "the PDF engine panicked while processing a document; PDF support is disabled until the server restarts",
91
+ ))),
92
+ Err(RecvTimeoutError::Timeout) => {
93
+ self.degrade(
94
+ "a PDF operation timed out; PDF support is disabled until the server restarts",
95
+ );
96
+ Err(PdfOperationError::TimedOut)
97
+ }
98
+ Err(RecvTimeoutError::Disconnected) => Err(PdfOperationError::Unavailable(
99
+ self.degrade(
100
+ "the isolated PDF operation ended without a result; PDF support is disabled until the server restarts",
101
+ ),
102
+ )),
103
+ }
104
+ }
105
+
106
+ fn degraded_reason(&self) -> Option<String> {
107
+ self.degraded_reason.get().cloned()
108
+ }
109
+
110
+ fn degrade(&self, reason: &str) -> String {
111
+ let _ = self.degraded_reason.set(reason.to_string());
112
+ self.degraded_reason
113
+ .get()
114
+ .cloned()
115
+ .unwrap_or_else(|| reason.to_string())
116
+ }
117
+ }
118
+
119
+ pub(super) fn run_pdf_operation<T: Send + 'static>(
120
+ operation: impl FnOnce() -> T + Send + 'static,
121
+ ) -> Result<T, PdfOperationError> {
122
+ PDF_RUNTIME.run(PDF_OPERATION_TIMEOUT, operation)
123
+ }
124
+
125
+ pub(super) fn pdfium_session() -> Result<(MutexGuard<'static, ()>, &'static Pdfium), String> {
126
+ if let Some(reason) = PDF_RUNTIME.degraded_reason() {
127
+ return Err(reason);
128
+ }
129
+ // The Pdfium C API uses process-global state, so overlapping document operations contaminate one another.
130
+ let operation = PDFIUM_OPERATIONS
131
+ .lock()
132
+ .map_err(|_| "the PDF engine operation lock was poisoned".to_string())?;
133
+ if let Some(reason) = PDF_RUNTIME.degraded_reason() {
134
+ return Err(reason);
135
+ }
136
+ let pdfium = pdfium()?;
137
+ Ok((operation, pdfium))
138
+ }
139
+
140
+ fn pdfium() -> Result<&'static Pdfium, String> {
141
+ if let Some(pdfium) = PDFIUM.get() {
142
+ return Ok(pdfium);
143
+ }
144
+ let _initialization = PDFIUM_INITIALIZATION
145
+ .lock()
146
+ .map_err(|_| "the PDF engine initialization lock was poisoned".to_string())?;
147
+ if let Some(pdfium) = PDFIUM.get() {
148
+ return Ok(pdfium);
149
+ }
150
+ let initialized = initialize_pdfium()?;
151
+ PDFIUM
152
+ .set(initialized)
153
+ .map_err(|_| "the PDF engine was initialized concurrently".to_string())?;
154
+ PDFIUM
155
+ .get()
156
+ .ok_or_else(|| "the PDF engine disappeared after initialization".to_string())
157
+ }
158
+
159
+ fn initialize_pdfium() -> Result<Pdfium, String> {
160
+ verify_embedded_bytes(BUNDLED_ARTIFACT)?;
161
+ let primary = cache_dir().ok_or_else(|| "no user cache location is available".to_string());
162
+ let fallback = fallback_dir();
163
+ let path = release_with_fallback(primary, &fallback, BUNDLED_ARTIFACT)?;
164
+ if !file_hash_matches(&path, BUNDLED_ARTIFACT.sha256)? {
165
+ return Err(format!(
166
+ "released engine changed before loading at {}",
167
+ path.display()
168
+ ));
169
+ }
170
+ let bindings = Pdfium::bind_to_library(&path)
171
+ .map_err(|error| format!("dynamic binding failed: {error}"))?;
172
+ Ok(Pdfium::new(bindings))
173
+ }
174
+
175
+ fn verify_embedded_bytes(artifact: EngineArtifact<'_>) -> Result<(), String> {
176
+ let actual = hex::encode(Sha256::digest(artifact.bytes));
177
+ if actual == artifact.sha256 {
178
+ Ok(())
179
+ } else {
180
+ Err(format!(
181
+ "embedded engine hash mismatch: expected {}, got {actual}",
182
+ artifact.sha256
183
+ ))
184
+ }
185
+ }
186
+
187
+ fn cache_dir() -> Option<PathBuf> {
188
+ #[cfg(windows)]
189
+ {
190
+ crate::session::var_os("LOCALAPPDATA")
191
+ .map(PathBuf::from)
192
+ .filter(|path| path.is_absolute())
193
+ .map(|path| path.join("fastctx"))
194
+ }
195
+ #[cfg(target_os = "macos")]
196
+ {
197
+ crate::session::var_os("HOME")
198
+ .map(PathBuf::from)
199
+ .filter(|path| path.is_absolute())
200
+ .map(|path| path.join("Library/Caches/fastctx"))
201
+ }
202
+ #[cfg(all(unix, not(target_os = "macos")))]
203
+ {
204
+ crate::session::var_os("XDG_CACHE_HOME")
205
+ .map(PathBuf::from)
206
+ .filter(|path| path.is_absolute())
207
+ .or_else(|| {
208
+ crate::session::var_os("HOME")
209
+ .map(PathBuf::from)
210
+ .filter(|path| path.is_absolute())
211
+ .map(|path| path.join(".cache"))
212
+ })
213
+ .map(|path| path.join("fastctx"))
214
+ }
215
+ }
216
+
217
+ fn fallback_dir() -> PathBuf {
218
+ let identity = cache_dir()
219
+ .or_else(|| crate::session::var_os("HOME").map(PathBuf::from))
220
+ .or_else(|| crate::session::var_os("USERPROFILE").map(PathBuf::from))
221
+ .unwrap_or_else(|| PathBuf::from("unknown-user"));
222
+ let digest = hex::encode(Sha256::digest(identity.to_string_lossy().as_bytes()));
223
+ crate::session::temp_dir().join(format!("fastctx-{}", &digest[..16]))
224
+ }
225
+
226
+ fn release_with_fallback(
227
+ primary: Result<PathBuf, String>,
228
+ fallback: &Path,
229
+ artifact: EngineArtifact<'_>,
230
+ ) -> Result<PathBuf, String> {
231
+ match primary.and_then(|directory| ensure_library_in(&directory, artifact)) {
232
+ Ok(path) => Ok(path),
233
+ Err(primary_error) => ensure_library_in(fallback, artifact).map_err(|fallback_error| {
234
+ format!(
235
+ "cache extraction failed: {primary_error}; temporary extraction failed: {fallback_error}"
236
+ )
237
+ }),
238
+ }
239
+ }
240
+
241
+ fn ensure_library_in(directory: &Path, artifact: EngineArtifact<'_>) -> Result<PathBuf, String> {
242
+ prepare_directory(directory)?;
243
+ let destination = destination_path(directory, artifact);
244
+ if file_hash_matches(&destination, artifact.sha256).unwrap_or(false) {
245
+ secure_file_permissions(&destination)?;
246
+ return Ok(destination);
247
+ }
248
+ let lock_path = directory.join(format!(
249
+ ".{}.{}.lock",
250
+ artifact.filename,
251
+ &artifact.sha256[..16]
252
+ ));
253
+ let _release_lock = acquire_release_lock(&lock_path)?;
254
+ if file_hash_matches(&destination, artifact.sha256).unwrap_or(false) {
255
+ secure_file_permissions(&destination)?;
256
+ return Ok(destination);
257
+ }
258
+ remove_stale_destination(&destination)?;
259
+
260
+ let sequence = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
261
+ let temporary = directory.join(format!(
262
+ ".{}.{}.{}.tmp",
263
+ artifact.filename,
264
+ std::process::id(),
265
+ sequence
266
+ ));
267
+ let write_result = write_temporary(&temporary, artifact.bytes).and_then(|()| {
268
+ fs::rename(&temporary, &destination).map_err(|error| {
269
+ format!(
270
+ "cannot atomically move {} to {}: {error}",
271
+ temporary.display(),
272
+ destination.display()
273
+ )
274
+ })
275
+ });
276
+ if let Err(error) = write_result {
277
+ let _ = fs::remove_file(&temporary);
278
+ return Err(error);
279
+ }
280
+ secure_file_permissions(&destination)?;
281
+ if file_hash_matches(&destination, artifact.sha256)? {
282
+ Ok(destination)
283
+ } else {
284
+ Err(format!(
285
+ "released engine failed hash verification at {}",
286
+ destination.display()
287
+ ))
288
+ }
289
+ }
290
+
291
+ fn acquire_release_lock(lock_path: &Path) -> Result<File, String> {
292
+ match fs::symlink_metadata(lock_path) {
293
+ Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
294
+ return Err(format!(
295
+ "release lock path is not a regular file: {}",
296
+ lock_path.display()
297
+ ));
298
+ }
299
+ Ok(_) => {}
300
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
301
+ Err(error) => {
302
+ return Err(format!(
303
+ "cannot inspect release lock {}: {error}",
304
+ lock_path.display()
305
+ ));
306
+ }
307
+ }
308
+ let mut options = OpenOptions::new();
309
+ options.create(true).read(true).write(true);
310
+ #[cfg(unix)]
311
+ {
312
+ use std::os::unix::fs::OpenOptionsExt;
313
+ options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
314
+ }
315
+ let mut file = options
316
+ .open(lock_path)
317
+ .map_err(|error| format!("cannot open release lock {}: {error}", lock_path.display()))?;
318
+ #[cfg(unix)]
319
+ {
320
+ use std::os::unix::fs::PermissionsExt;
321
+ fs::set_permissions(lock_path, fs::Permissions::from_mode(0o600)).map_err(|error| {
322
+ format!(
323
+ "cannot secure release lock {}: {error}",
324
+ lock_path.display()
325
+ )
326
+ })?;
327
+ }
328
+ file.lock_exclusive()
329
+ .map_err(|error| format!("cannot lock release file {}: {error}", lock_path.display()))?;
330
+ file.set_len(0)
331
+ .map_err(|error| format!("cannot reset release lock {}: {error}", lock_path.display()))?;
332
+ writeln!(file, "{}", std::process::id())
333
+ .map_err(|error| format!("cannot write release lock {}: {error}", lock_path.display()))?;
334
+ Ok(file)
335
+ }
336
+
337
+ fn destination_path(directory: &Path, artifact: EngineArtifact<'_>) -> PathBuf {
338
+ let release = artifact.release_tag.replace('/', "-");
339
+ let original = Path::new(artifact.filename);
340
+ let stem = original
341
+ .file_stem()
342
+ .and_then(|name| name.to_str())
343
+ .unwrap_or("pdfium");
344
+ let extension = original
345
+ .extension()
346
+ .and_then(|value| value.to_str())
347
+ .unwrap_or("bin");
348
+ directory.join(format!(
349
+ "{stem}-{release}-{}.{}",
350
+ &artifact.sha256[..16],
351
+ extension
352
+ ))
353
+ }
354
+
355
+ fn remove_stale_destination(path: &Path) -> Result<(), String> {
356
+ match fs::remove_file(path) {
357
+ Ok(()) => Ok(()),
358
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
359
+ Err(error) => Err(format!(
360
+ "cannot replace corrupted cached engine {}: {error}",
361
+ path.display()
362
+ )),
363
+ }
364
+ }
365
+
366
+ fn write_temporary(path: &Path, bytes: &[u8]) -> Result<(), String> {
367
+ let mut options = OpenOptions::new();
368
+ options.create_new(true).write(true);
369
+ #[cfg(unix)]
370
+ {
371
+ use std::os::unix::fs::OpenOptionsExt;
372
+ options.mode(0o600);
373
+ }
374
+ let mut file = options
375
+ .open(path)
376
+ .map_err(|error| format!("cannot create {}: {error}", path.display()))?;
377
+ file.write_all(bytes)
378
+ .map_err(|error| format!("cannot write {}: {error}", path.display()))?;
379
+ file.sync_all()
380
+ .map_err(|error| format!("cannot sync {}: {error}", path.display()))
381
+ }
382
+
383
+ fn file_hash_matches(path: &Path, expected: &str) -> Result<bool, String> {
384
+ let metadata = match fs::symlink_metadata(path) {
385
+ Ok(metadata) => metadata,
386
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
387
+ Err(error) => {
388
+ return Err(format!(
389
+ "cannot inspect cached engine {}: {error}",
390
+ path.display()
391
+ ));
392
+ }
393
+ };
394
+ if metadata.file_type().is_symlink() || !metadata.is_file() {
395
+ return Err(format!(
396
+ "cached engine path is not a regular file: {}",
397
+ path.display()
398
+ ));
399
+ }
400
+ let mut options = OpenOptions::new();
401
+ options.read(true);
402
+ #[cfg(unix)]
403
+ {
404
+ use std::os::unix::fs::OpenOptionsExt;
405
+ options.custom_flags(libc::O_NOFOLLOW);
406
+ }
407
+ let mut file = options
408
+ .open(path)
409
+ .map_err(|error| format!("cannot verify cached engine {}: {error}", path.display()))?;
410
+ let mut hasher = Sha256::new();
411
+ let mut buffer = [0_u8; 64 * 1024];
412
+ loop {
413
+ let count = file
414
+ .read(&mut buffer)
415
+ .map_err(|error| format!("cannot verify cached engine {}: {error}", path.display()))?;
416
+ if count == 0 {
417
+ break;
418
+ }
419
+ hasher.update(&buffer[..count]);
420
+ }
421
+ Ok(hex::encode(hasher.finalize()) == expected)
422
+ }
423
+
424
+ fn prepare_directory(path: &Path) -> Result<(), String> {
425
+ match fs::symlink_metadata(path) {
426
+ Ok(metadata) => {
427
+ if metadata.file_type().is_symlink() || !metadata.is_dir() {
428
+ return Err(format!(
429
+ "cache location is not a regular directory: {}",
430
+ path.display()
431
+ ));
432
+ }
433
+ }
434
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
435
+ fs::create_dir_all(path)
436
+ .map_err(|error| format!("cannot create {}: {error}", path.display()))?;
437
+ }
438
+ Err(error) => return Err(format!("cannot inspect {}: {error}", path.display())),
439
+ }
440
+ #[cfg(unix)]
441
+ {
442
+ use std::os::unix::fs::PermissionsExt;
443
+ fs::set_permissions(path, fs::Permissions::from_mode(0o700))
444
+ .map_err(|error| format!("cannot secure {}: {error}", path.display()))?;
445
+ }
446
+ Ok(())
447
+ }
448
+
449
+ fn secure_file_permissions(_path: &Path) -> Result<(), String> {
450
+ #[cfg(unix)]
451
+ {
452
+ use std::os::unix::fs::PermissionsExt;
453
+ fs::set_permissions(_path, fs::Permissions::from_mode(0o500))
454
+ .map_err(|error| format!("cannot secure {}: {error}", _path.display()))?;
455
+ }
456
+ Ok(())
457
+ }
458
+
459
+ #[cfg(test)]
460
+ mod tests {
461
+ use super::{
462
+ EngineArtifact, PdfOperationError, PdfRuntime, destination_path, ensure_library_in,
463
+ release_with_fallback,
464
+ };
465
+ use std::fs;
466
+ use std::sync::atomic::{AtomicBool, Ordering};
467
+ use std::sync::{Arc, Barrier};
468
+ use std::time::{Duration, Instant};
469
+
470
+ const TEST_ARTIFACT: EngineArtifact<'static> = EngineArtifact {
471
+ bytes: b"test-engine",
472
+ filename: "pdfium.test",
473
+ sha256: "f3d95bb7396a6919739c96947848496a1dd3473dc6e0061e51a7092bbd26a3d3",
474
+ release_tag: "test/1",
475
+ };
476
+
477
+ #[test]
478
+ fn corrupted_cache_is_replaced_and_then_reused() {
479
+ let temp = tempfile::tempdir().unwrap();
480
+ let first = ensure_library_in(temp.path(), TEST_ARTIFACT).unwrap();
481
+ assert_eq!(fs::read(&first).unwrap(), TEST_ARTIFACT.bytes);
482
+ #[cfg(unix)]
483
+ {
484
+ use std::os::unix::fs::PermissionsExt;
485
+ fs::set_permissions(&first, fs::Permissions::from_mode(0o600)).unwrap();
486
+ }
487
+ fs::write(&first, b"corrupt").unwrap();
488
+ let repaired = ensure_library_in(temp.path(), TEST_ARTIFACT).unwrap();
489
+ assert_eq!(first, repaired);
490
+ assert_eq!(fs::read(repaired).unwrap(), TEST_ARTIFACT.bytes);
491
+ }
492
+
493
+ #[test]
494
+ fn unwritable_primary_shape_falls_back_to_the_temporary_location() {
495
+ let temp = tempfile::tempdir().unwrap();
496
+ let primary = temp.path().join("not-a-directory");
497
+ fs::write(&primary, b"occupied").unwrap();
498
+ let fallback = temp.path().join("fallback");
499
+ let released = release_with_fallback(Ok(primary), &fallback, TEST_ARTIFACT).unwrap();
500
+ assert_eq!(released, destination_path(&fallback, TEST_ARTIFACT));
501
+ assert_eq!(fs::read(released).unwrap(), TEST_ARTIFACT.bytes);
502
+ }
503
+
504
+ #[cfg(unix)]
505
+ #[test]
506
+ fn read_only_cache_directory_falls_back_to_temporary_storage() {
507
+ use std::os::unix::fs::PermissionsExt;
508
+
509
+ let temp = tempfile::tempdir().unwrap();
510
+ let read_only_parent = temp.path().join("read-only");
511
+ fs::create_dir(&read_only_parent).unwrap();
512
+ fs::set_permissions(&read_only_parent, fs::Permissions::from_mode(0o555)).unwrap();
513
+ let primary = read_only_parent.join("cache");
514
+ let fallback = temp.path().join("fallback");
515
+ let result = release_with_fallback(Ok(primary), &fallback, TEST_ARTIFACT);
516
+ fs::set_permissions(&read_only_parent, fs::Permissions::from_mode(0o755)).unwrap();
517
+ let released = result.unwrap();
518
+ assert_eq!(released, destination_path(&fallback, TEST_ARTIFACT));
519
+ }
520
+
521
+ #[cfg(unix)]
522
+ #[test]
523
+ fn symlink_cache_directory_is_rejected_and_falls_back() {
524
+ use std::os::unix::fs::symlink;
525
+
526
+ let temp = tempfile::tempdir().unwrap();
527
+ let actual = temp.path().join("actual");
528
+ fs::create_dir(&actual).unwrap();
529
+ let primary = temp.path().join("linked-cache");
530
+ symlink(&actual, &primary).unwrap();
531
+ let fallback = temp.path().join("fallback");
532
+ let released = release_with_fallback(Ok(primary), &fallback, TEST_ARTIFACT).unwrap();
533
+ assert_eq!(released, destination_path(&fallback, TEST_ARTIFACT));
534
+ assert_eq!(fs::read_dir(actual).unwrap().count(), 0);
535
+ }
536
+
537
+ #[cfg(unix)]
538
+ #[test]
539
+ fn symlink_release_lock_cannot_redirect_writes() {
540
+ use std::os::unix::fs::symlink;
541
+
542
+ let temp = tempfile::tempdir().unwrap();
543
+ let primary = temp.path().join("primary");
544
+ fs::create_dir(&primary).unwrap();
545
+ let victim = temp.path().join("victim.txt");
546
+ fs::write(&victim, b"do not touch").unwrap();
547
+ let lock = primary.join(format!(
548
+ ".{}.{}.lock",
549
+ TEST_ARTIFACT.filename,
550
+ &TEST_ARTIFACT.sha256[..16]
551
+ ));
552
+ symlink(&victim, lock).unwrap();
553
+ let fallback = temp.path().join("fallback");
554
+ let released = release_with_fallback(Ok(primary), &fallback, TEST_ARTIFACT).unwrap();
555
+ assert_eq!(released, destination_path(&fallback, TEST_ARTIFACT));
556
+ assert_eq!(fs::read(victim).unwrap(), b"do not touch");
557
+ }
558
+
559
+ #[test]
560
+ fn concurrent_first_release_converges_on_one_valid_file() {
561
+ let temp = tempfile::tempdir().unwrap();
562
+ let directory = temp.path().to_path_buf();
563
+ let barrier = Arc::new(Barrier::new(8));
564
+ let handles = (0..8)
565
+ .map(|_| {
566
+ let directory = directory.clone();
567
+ let barrier = Arc::clone(&barrier);
568
+ std::thread::spawn(move || {
569
+ barrier.wait();
570
+ ensure_library_in(&directory, TEST_ARTIFACT).unwrap()
571
+ })
572
+ })
573
+ .collect::<Vec<_>>();
574
+ let paths = handles
575
+ .into_iter()
576
+ .map(|handle| handle.join().unwrap())
577
+ .collect::<Vec<_>>();
578
+ assert!(paths.windows(2).all(|pair| pair[0] == pair[1]));
579
+ assert_eq!(fs::read(&paths[0]).unwrap(), TEST_ARTIFACT.bytes);
580
+ let files = fs::read_dir(&directory)
581
+ .unwrap()
582
+ .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
583
+ .collect::<Vec<_>>();
584
+ assert_eq!(
585
+ files.iter().filter(|name| !name.ends_with(".lock")).count(),
586
+ 1
587
+ );
588
+ assert!(files.iter().any(|name| name.ends_with(".lock")));
589
+ }
590
+
591
+ #[test]
592
+ fn pdf_runtime_catches_panics_and_disables_future_operations() {
593
+ let runtime = PdfRuntime::new();
594
+ let error = runtime
595
+ .run(Duration::from_secs(1), || -> usize {
596
+ panic!("synthetic PDF panic")
597
+ })
598
+ .unwrap_err();
599
+ assert!(matches!(
600
+ error,
601
+ PdfOperationError::Unavailable(ref reason)
602
+ if reason.contains("panicked") && reason.contains("server restarts")
603
+ ));
604
+
605
+ let executed = Arc::new(AtomicBool::new(false));
606
+ let marker = Arc::clone(&executed);
607
+ let second = runtime.run(Duration::from_secs(1), move || {
608
+ marker.store(true, Ordering::SeqCst);
609
+ 7
610
+ });
611
+ assert!(matches!(second, Err(PdfOperationError::Unavailable(_))));
612
+ assert!(!executed.load(Ordering::SeqCst));
613
+ }
614
+
615
+ #[test]
616
+ fn pdf_runtime_times_out_without_blocking_the_caller_and_then_degrades() {
617
+ let runtime = PdfRuntime::new();
618
+ let started = Instant::now();
619
+ let result = runtime.run(Duration::from_millis(20), || {
620
+ std::thread::sleep(Duration::from_millis(250));
621
+ 7
622
+ });
623
+ assert_eq!(result, Err(PdfOperationError::TimedOut));
624
+ assert!(started.elapsed() < Duration::from_millis(200));
625
+ assert!(matches!(
626
+ runtime.run(Duration::from_secs(1), || 9),
627
+ Err(PdfOperationError::Unavailable(ref reason)) if reason.contains("timed out")
628
+ ));
629
+ }
630
+
631
+ #[cfg(unix)]
632
+ #[test]
633
+ fn released_engine_and_cache_directory_are_owner_only() {
634
+ use std::os::unix::fs::PermissionsExt;
635
+
636
+ let temp = tempfile::tempdir().unwrap();
637
+ let directory = temp.path().join("secure-cache");
638
+ let released = ensure_library_in(&directory, TEST_ARTIFACT).unwrap();
639
+ assert_eq!(
640
+ fs::metadata(&directory).unwrap().permissions().mode() & 0o777,
641
+ 0o700
642
+ );
643
+ assert_eq!(
644
+ fs::metadata(released).unwrap().permissions().mode() & 0o777,
645
+ 0o500
646
+ );
647
+ }
648
+
649
+ #[cfg(unix)]
650
+ #[test]
651
+ fn valid_cached_engine_permissions_are_repaired_on_reuse() {
652
+ use std::os::unix::fs::PermissionsExt;
653
+
654
+ let temp = tempfile::tempdir().unwrap();
655
+ let released = ensure_library_in(temp.path(), TEST_ARTIFACT).unwrap();
656
+ fs::set_permissions(&released, fs::Permissions::from_mode(0o600)).unwrap();
657
+ let reused = ensure_library_in(temp.path(), TEST_ARTIFACT).unwrap();
658
+ assert_eq!(released, reused);
659
+ assert_eq!(
660
+ fs::metadata(reused).unwrap().permissions().mode() & 0o777,
661
+ 0o500
662
+ );
663
+ }
664
+ }