1use crate::errors::{Result, WinxError};
4use serde::{Deserialize, Serialize};
5#[cfg(target_os = "linux")]
6use sha2::{Digest, Sha256};
7use std::path::{Path, PathBuf};
8
9#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
10pub struct TemporaryReleaseTarget {
11 pub file_path: String,
12 pub expected_revision: String,
13 pub artifact_generation: String,
14}
15
16#[derive(Clone, Debug, Serialize)]
17pub struct TemporaryArtifactMetadata {
18 pub managed: bool,
19 pub artifact_generation: String,
20 pub in_use: Option<bool>,
22 pub releasable: Option<bool>,
23}
24
25#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
26pub struct PhysicalIdentity {
27 pub device: u64,
28 pub inode: u64,
29 pub bytes: u64,
30 pub changed_seconds: i64,
31 pub changed_nanos: i64,
32 pub modified_seconds: i64,
33 pub modified_nanos: i64,
34}
35
36#[derive(Clone, Debug, Deserialize, Serialize)]
37pub struct PreparedTemporaryTarget {
38 pub target: TemporaryReleaseTarget,
39 pub identity: PhysicalIdentity,
40}
41
42#[derive(Clone, Debug, Deserialize, Serialize)]
43pub struct TemporaryReleaseRequest {
44 pub thread_id: String,
45 pub workspace_root: PathBuf,
46 pub files: Vec<PreparedTemporaryTarget>,
47 pub expected_guardian_epoch: Option<String>,
48}
49
50#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
51pub struct TemporaryReleaseFileResult {
52 pub file_path: String,
53 pub artifact_generation: String,
54 pub status: String,
55 pub reclaimed_bytes: u64,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub recovery_path: Option<String>,
58}
59
60#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
61pub struct TemporaryReleaseResult {
62 pub files: Vec<TemporaryReleaseFileResult>,
63 pub reclaimed_bytes: u64,
64 pub partial: bool,
65}
66
67pub fn validate_target(workspace: &Path, thread: &str, path: &Path) -> Result<()> {
68 let info = super::session_info(workspace, thread);
69 if !path.is_absolute()
70 || path.components().any(|part| {
71 !matches!(
72 part,
73 std::path::Component::RootDir
74 | std::path::Component::Prefix(_)
75 | std::path::Component::Normal(_)
76 )
77 })
78 {
79 return Err(failure(path, "unsafe_path: an exact absolute helper path is required"));
80 }
81 super::validate_session_relative_path(path, &info)?;
82 if path
83 .components()
84 .any(|part| part.as_os_str().to_string_lossy().starts_with(".winx-release-"))
85 {
86 return Err(failure(path, "unsafe_path: quarantined artifacts require recovery"));
87 }
88 Ok(())
89}
90
91fn failure(path: &Path, message: &str) -> WinxError {
92 WinxError::FileOperationDenied { path: path.to_path_buf(), message: message.into() }
93}
94
95#[cfg(target_os = "linux")]
96fn identity(file: &std::fs::File) -> std::io::Result<PhysicalIdentity> {
97 use std::os::unix::fs::MetadataExt as _;
98 let metadata = file.metadata()?;
99 if !metadata.is_file() || metadata.nlink() != 1 || metadata.len() > super::MAX_FILE_BYTES {
100 return Err(std::io::Error::new(
101 std::io::ErrorKind::InvalidInput,
102 "only bounded regular helpers with one link may be released",
103 ));
104 }
105 Ok(PhysicalIdentity {
106 device: metadata.dev(),
107 inode: metadata.ino(),
108 bytes: metadata.len(),
109 changed_seconds: metadata.ctime(),
110 changed_nanos: metadata.ctime_nsec(),
111 modified_seconds: metadata.mtime(),
112 modified_nanos: metadata.mtime_nsec(),
113 })
114}
115
116#[cfg(target_os = "linux")]
117fn content(file: &mut std::fs::File) -> std::io::Result<Vec<u8>> {
118 use std::io::{Read as _, Seek as _};
119 file.rewind()?;
120 let mut bytes = Vec::new();
121 file.take(super::MAX_FILE_BYTES + 1).read_to_end(&mut bytes)?;
122 if bytes.len() as u64 > super::MAX_FILE_BYTES {
123 return Err(std::io::Error::new(
124 std::io::ErrorKind::InvalidInput,
125 "helper exceeds size limit",
126 ));
127 }
128 Ok(bytes)
129}
130
131#[cfg(target_os = "linux")]
132fn revision(bytes: &[u8]) -> String {
133 format!("sha256:{}", hex_digest(Sha256::digest(bytes).as_slice()))
134}
135
136#[cfg(target_os = "linux")]
137fn hex_digest(bytes: &[u8]) -> String {
138 use std::fmt::Write as _;
139 bytes.iter().fold(String::with_capacity(bytes.len() * 2), |mut output, byte| {
140 let _ = write!(output, "{byte:02x}");
141 output
142 })
143}
144
145#[cfg(target_os = "linux")]
146fn generation(
147 workspace: &Path,
148 thread: &str,
149 path: &Path,
150 observed: &PhysicalIdentity,
151 revision: &str,
152) -> String {
153 use std::os::unix::ffi::OsStrExt as _;
154 static SECRET: std::sync::LazyLock<[u8; 32]> = std::sync::LazyLock::new(rand::random);
155 let mut inner_pad = [0x36_u8; 64];
158 let mut outer_pad = [0x5c_u8; 64];
159 for (index, byte) in SECRET.iter().enumerate() {
160 inner_pad[index] ^= byte;
161 outer_pad[index] ^= byte;
162 }
163 let mut inner = Sha256::new();
164 inner.update(inner_pad);
165 let physical = [
166 observed.device,
167 observed.inode,
168 observed.bytes,
169 observed.changed_seconds.cast_unsigned(),
170 observed.changed_nanos.cast_unsigned(),
171 observed.modified_seconds.cast_unsigned(),
172 observed.modified_nanos.cast_unsigned(),
173 ]
174 .into_iter()
175 .flat_map(u64::to_le_bytes)
176 .collect::<Vec<_>>();
177 for part in [
178 b"WinxTemporaryRead/v1".as_slice(),
179 workspace.as_os_str().as_bytes(),
180 thread.as_bytes(),
181 path.as_os_str().as_bytes(),
182 physical.as_slice(),
183 revision.as_bytes(),
184 ] {
185 inner.update((part.len() as u64).to_le_bytes());
186 inner.update(part);
187 }
188 let mut outer = Sha256::new();
189 outer.update(outer_pad);
190 outer.update(inner.finalize());
191 format!("temporary-v1:{}", hex_digest(outer.finalize().as_slice()))
192}
193
194#[cfg(target_os = "linux")]
197pub fn read_managed(
198 workspace: &Path,
199 thread: &str,
200 path: &Path,
201) -> Result<Option<(String, TemporaryArtifactMetadata)>> {
202 if !path.starts_with(super::session_info(workspace, thread).directory) {
203 return Ok(None);
204 }
205 validate_target(workspace, thread, path)?;
206 let mut opened = crate::os::temporary::open(path).map_err(|_| {
207 failure(path, "unsafe_path: cannot open managed helper without following links")
208 })?;
209 let before = identity(&opened.file).map_err(|_| {
210 failure(path, "unsafe_path: helper is not a supported single-link regular file")
211 })?;
212 let bytes = content(&mut opened.file)
213 .map_err(|_| failure(path, "io_error: cannot read managed helper"))?;
214 if identity(&opened.file).ok().as_ref() != Some(&before) {
215 return Err(failure(path, "generation_conflict: helper changed during read"));
216 }
217 let artifact_generation = generation(workspace, thread, path, &before, &revision(&bytes));
218 let text =
219 String::from_utf8(bytes).map_err(|_| failure(path, "managed helper is not UTF-8"))?;
220 Ok(Some((
221 text,
222 TemporaryArtifactMetadata {
223 managed: true,
224 artifact_generation,
225 in_use: None,
226 releasable: None,
227 },
228 )))
229}
230
231#[cfg(not(target_os = "linux"))]
232pub fn read_managed(
233 _workspace: &Path,
234 _thread: &str,
235 _path: &Path,
236) -> Result<Option<(String, TemporaryArtifactMetadata)>> {
237 Ok(None)
238}
239
240#[cfg(target_os = "linux")]
241pub fn prepare(
242 workspace: &Path,
243 thread: &str,
244 targets: &[TemporaryReleaseTarget],
245) -> Result<TemporaryReleaseRequest> {
246 let mut files = Vec::with_capacity(targets.len());
247 for target in targets {
248 let path = Path::new(&target.file_path);
249 validate_target(workspace, thread, path)?;
250 let mut opened = crate::os::temporary::open(path)
251 .map_err(|_| failure(path, "unsafe_path: helper cannot be opened safely"))?;
252 let observed = identity(&opened.file)
253 .map_err(|_| failure(path, "unsafe_path: helper physical identity is unavailable"))?;
254 let bytes = content(&mut opened.file)
255 .map_err(|_| failure(path, "io_error: helper cannot be read"))?;
256 if identity(&opened.file).ok().as_ref() != Some(&observed) {
257 return Err(failure(path, "generation_conflict: helper changed during validation"));
258 }
259 if revision(&bytes) != target.expected_revision {
260 return Err(failure(path, "revision_conflict: re-read the helper"));
261 }
262 let current = generation(workspace, thread, path, &observed, &target.expected_revision);
263 if current.len() != target.artifact_generation.len()
264 || current
265 .bytes()
266 .zip(target.artifact_generation.bytes())
267 .fold(0_u8, |difference, (a, b)| difference | (a ^ b))
268 != 0
269 {
270 return Err(failure(
271 path,
272 "generation_conflict: a fresh ReadFiles generation is required",
273 ));
274 }
275 files.push(PreparedTemporaryTarget { target: target.clone(), identity: observed });
276 }
277 Ok(TemporaryReleaseRequest {
278 thread_id: thread.into(),
279 workspace_root: workspace.into(),
280 files,
281 expected_guardian_epoch: None,
282 })
283}
284
285#[cfg(not(target_os = "linux"))]
286pub fn prepare(
287 workspace: &Path,
288 _thread: &str,
289 _targets: &[TemporaryReleaseTarget],
290) -> Result<TemporaryReleaseRequest> {
291 Err(failure(workspace, "unsupported: safe temporary release is unavailable on this platform"))
292}
293
294#[cfg(target_os = "linux")]
295pub fn release(request: &TemporaryReleaseRequest) -> Result<TemporaryReleaseResult> {
296 release_with_hook(request, || {})
297}
298
299#[cfg(target_os = "linux")]
300fn release_with_hook(
301 request: &TemporaryReleaseRequest,
302 before_claim: impl FnOnce(),
303) -> Result<TemporaryReleaseResult> {
304 if request.files.is_empty() || request.files.len() > 100 {
305 return Err(failure(&request.workspace_root, "release requires 1–100 exact targets"));
306 }
307 protect_backing_roots(request)?;
308 let mut plans = Vec::with_capacity(request.files.len());
309 let mut seen = std::collections::HashSet::new();
310 for expected in &request.files {
313 let path = Path::new(&expected.target.file_path);
314 validate_target(&request.workspace_root, &request.thread_id, path)?;
315 if !seen.insert(path) {
316 return Err(failure(path, "duplicate release target"));
317 }
318 let mut opened = crate::os::temporary::open(path)
319 .map_err(|_| failure(path, "unsafe_path: cannot open release target"))?;
320 if identity(&opened.file).ok().as_ref() != Some(&expected.identity) {
321 return Err(failure(path, "generation_conflict: target was replaced or changed"));
322 }
323 let bytes = content(&mut opened.file)
324 .map_err(|_| failure(path, "io_error: cannot inspect release target"))?;
325 if revision(&bytes) != expected.target.expected_revision
326 || identity(&opened.file).ok().as_ref() != Some(&expected.identity)
327 {
328 return Err(failure(path, "revision_conflict: target changed before release"));
329 }
330 plans.push(opened);
331 }
332 before_claim();
333 let mut result = TemporaryReleaseResult::default();
334 let mut stopped = false;
335 for (expected, opened) in request.files.iter().zip(plans) {
336 let mut row = TemporaryReleaseFileResult {
337 file_path: expected.target.file_path.clone(),
338 artifact_generation: expected.target.artifact_generation.clone(),
339 status: "not_attempted".into(),
340 reclaimed_bytes: 0,
341 recovery_path: None,
342 };
343 if !stopped {
344 if let Ok(claim) = opened.claim() {
345 let recovery = Path::new(&row.file_path).parent().map(|parent| {
346 parent.join(claim.recovery_name()).to_string_lossy().into_owned()
347 });
348 let valid = claim
349 .open_captured()
350 .and_then(|mut file| {
351 let current = identity(&file)?;
352 Ok(current.device == expected.identity.device
355 && current.inode == expected.identity.inode
356 && revision(&content(&mut file)?) == expected.target.expected_revision)
357 })
358 .unwrap_or(false);
359 if !valid {
360 drop(claim);
361 row.status = "generation_conflict".into();
362 row.recovery_path =
363 recovery.filter(|path| std::fs::symlink_metadata(path).is_ok());
364 stopped = true;
365 } else if claim.remove().is_ok() {
366 row.status = "released".into();
367 row.reclaimed_bytes = expected.identity.bytes;
368 result.reclaimed_bytes =
369 result.reclaimed_bytes.saturating_add(row.reclaimed_bytes);
370 } else {
371 row.status = "io_error".into();
372 row.recovery_path =
373 recovery.filter(|path| std::fs::symlink_metadata(path).is_ok());
374 stopped = true;
375 }
376 } else {
377 row.status = "io_error".into();
378 stopped = true;
379 }
380 }
381 result.files.push(row);
382 }
383 result.partial = stopped && result.files.iter().any(|row| row.status == "released");
384 Ok(result)
385}
386
387#[cfg(not(target_os = "linux"))]
388pub fn release(request: &TemporaryReleaseRequest) -> Result<TemporaryReleaseResult> {
389 Err(failure(
390 &request.workspace_root,
391 "unsupported: safe temporary release is unavailable on this platform",
392 ))
393}
394
395#[cfg(target_os = "linux")]
396fn protect_backing_roots(request: &TemporaryReleaseRequest) -> Result<()> {
397 let managed = super::session_info(&request.workspace_root, &request.thread_id).directory;
398 let home = home::home_dir().ok_or_else(|| {
399 failure(&managed, "unresolved_reference: private history root is unavailable")
400 })?;
401 let mut remaining = super::MAX_SCAN_ENTRIES;
402 for backing in
403 [request.workspace_root.join(".winx/scratch"), home.join(".local/share/winx/executions")]
404 {
405 let directory = match crate::os::temporary::directory(&backing) {
406 Ok(directory) => directory,
407 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
408 Err(_) => return Err(failure(
409 &managed,
410 "unresolved_reference: output backing hierarchy cannot be opened without aliases",
411 )),
412 };
413 if backing.canonicalize().map_or(true, |actual| actual.starts_with(&managed)) {
416 return Err(failure(
417 &managed,
418 "referenced: output backing root aliases managed helpers",
419 ));
420 }
421 inspect_backing_directory(&directory, request, 0, &mut remaining)
422 .map_err(|_| failure(&managed,
423 "unresolved_reference: retained output inspection found an alias, unsupported entry, inaccessible path, or scan limit"))?;
424 }
425 Ok(())
426}
427
428#[cfg(target_os = "linux")]
433fn inspect_backing_directory(
434 directory: &std::fs::File,
435 request: &TemporaryReleaseRequest,
436 depth: usize,
437 remaining: &mut usize,
438) -> std::io::Result<()> {
439 use std::os::unix::fs::MetadataExt as _;
440 let unknown = || std::io::Error::other("retained backing protection is unknown");
441 if depth > super::MAX_SCAN_DEPTH || *remaining == 0 {
442 return Err(unknown());
443 }
444 *remaining -= 1;
445 for entry in crate::os::temporary::directory_entries(directory)? {
446 if *remaining == 0 {
447 return Err(unknown());
448 }
449 *remaining -= 1;
450 let entry = entry?;
451 let metadata = entry.metadata()?; if metadata.is_dir() {
453 let child = crate::os::temporary::directory_child(directory, &entry.file_name())?;
454 inspect_backing_directory(&child, request, depth + 1, remaining)?;
455 } else if !metadata.is_file()
456 || request.files.iter().any(|expected| {
457 expected.identity.device == metadata.dev()
458 && expected.identity.inode == metadata.ino()
459 })
460 {
461 return Err(unknown());
462 }
463 }
464 Ok(())
465}
466
467#[cfg(all(test, target_os = "linux"))]
468mod tests {
469 #![allow(clippy::unwrap_used, clippy::expect_used)]
470 use super::*;
471 use std::fs;
472
473 fn fixture() -> anyhow::Result<(tempfile::TempDir, PathBuf, PathBuf)> {
474 let directory = tempfile::tempdir()?;
475 let workspace = directory.path().canonicalize()?;
476 let helper = super::super::session_info(&workspace, "one").directory.join("helper.txt");
477 fs::create_dir_all(helper.parent().unwrap())?;
478 fs::write(&helper, "original\n")?;
479 Ok((directory, workspace, helper))
480 }
481
482 fn target(workspace: &Path, path: &Path) -> anyhow::Result<TemporaryReleaseTarget> {
483 let (text, metadata) = read_managed(workspace, "one", path)?
484 .ok_or_else(|| anyhow::anyhow!("managed metadata missing"))?;
485 Ok(TemporaryReleaseTarget {
486 file_path: path.to_string_lossy().into_owned(),
487 expected_revision: revision(text.as_bytes()),
488 artifact_generation: metadata.artifact_generation,
489 })
490 }
491
492 #[test]
493 fn release_frees_exact_young_helpers_at_the_file_cap() -> anyhow::Result<()> {
494 let (_dir, workspace, helper) = fixture()?;
495 let parent = helper.parent().unwrap();
496 for index in 1..super::super::MAX_SESSION_FILES {
497 fs::write(parent.join(format!("keep-{index}")), "keep")?;
498 }
499 let request = prepare(&workspace, "one", &[target(&workspace, &helper)?])?;
500 let result = release(&request)?;
501 assert_eq!(result.files[0].status, "released");
502 assert_eq!(result.reclaimed_bytes, 9);
503 assert!(!helper.exists());
504 assert_eq!(fs::read_dir(parent)?.count(), 127);
505 fs::write(parent.join("new-helper"), "new")?;
506 assert_eq!(fs::read_dir(parent)?.count(), 128);
507 Ok(())
508 }
509
510 #[test]
511 fn same_content_replacement_invalidates_the_observed_generation() -> anyhow::Result<()> {
512 let (_dir, workspace, helper) = fixture()?;
513 let observed = target(&workspace, &helper)?;
514 let replacement = helper.with_file_name("replacement");
515 fs::write(&replacement, "original\n")?;
516 fs::rename(&replacement, &helper)?;
517 assert!(prepare(&workspace, "one", &[observed]).is_err());
518 assert_eq!(fs::read_to_string(&helper)?, "original\n");
519 Ok(())
520 }
521
522 #[test]
523 fn release_preflights_the_entire_batch_before_mutation() -> anyhow::Result<()> {
524 let (_dir, workspace, helper) = fixture()?;
525 let second = helper.with_file_name("second");
526 fs::write(&second, "second")?;
527 let request = prepare(
528 &workspace,
529 "one",
530 &[target(&workspace, &helper)?, target(&workspace, &second)?],
531 )?;
532 fs::write(&second, "changed")?;
533 assert!(release(&request).is_err());
534 assert_eq!(fs::read_to_string(&helper)?, "original\n");
535 assert_eq!(fs::read_to_string(&second)?, "changed");
536 Ok(())
537 }
538
539 #[test]
540 fn replacement_installed_between_preflight_and_claim_is_preserved() -> anyhow::Result<()> {
541 let (_dir, workspace, helper) = fixture()?;
542 let request = prepare(&workspace, "one", &[target(&workspace, &helper)?])?;
543 let replacement = helper.with_file_name("replacement");
544 fs::write(&replacement, "original\n")?;
545 let result = release_with_hook(&request, || fs::rename(&replacement, &helper).unwrap())?;
546 assert_eq!(result.files[0].status, "generation_conflict");
547 assert_eq!(result.reclaimed_bytes, 0);
548 assert_eq!(fs::read_to_string(&helper)?, "original\n");
549 Ok(())
550 }
551
552 #[test]
553 fn release_rejects_other_scope_source_symlink_and_hardlink() -> anyhow::Result<()> {
554 let (_dir, workspace, helper) = fixture()?;
555 let observed = target(&workspace, &helper)?;
556 assert!(prepare(&workspace, "two", std::slice::from_ref(&observed)).is_err());
557 let source = workspace.join("source.rs");
558 fs::write(&source, "source")?;
559 let mut source_target = observed.clone();
560 source_target.file_path = source.to_string_lossy().into_owned();
561 assert!(prepare(&workspace, "one", &[source_target]).is_err());
562 let linked = helper.with_file_name("linked");
563 fs::hard_link(&helper, &linked)?;
564 assert!(prepare(&workspace, "one", std::slice::from_ref(&observed)).is_err());
565 fs::remove_file(&linked)?;
566 fs::remove_file(&helper)?;
567 std::os::unix::fs::symlink(&source, &helper)?;
568 assert!(prepare(&workspace, "one", &[observed]).is_err());
569 assert_eq!(fs::read_to_string(source)?, "source");
570 Ok(())
571 }
572
573 #[test]
574 fn retained_output_namespace_is_preserved_and_aliases_block_cleanup() -> anyhow::Result<()> {
575 let (_dir, workspace, helper) = fixture()?;
576 let scratch = workspace.join(".winx/scratch");
577 fs::create_dir_all(&scratch)?;
578 let output = scratch.join("bash-output.txt");
579 fs::write(&output, "retained output")?;
580 release(&prepare(&workspace, "one", &[target(&workspace, &helper)?])?)?;
581 assert_eq!(fs::read_to_string(&output)?, "retained output");
582 fs::write(&helper, "again")?;
583 fs::remove_file(&output)?;
584 fs::remove_dir(&scratch)?;
585 std::os::unix::fs::symlink(helper.parent().unwrap(), &scratch)?;
586 let request = prepare(&workspace, "one", &[target(&workspace, &helper)?])?;
587 assert!(release(&request).is_err());
588 assert_eq!(fs::read_to_string(helper)?, "again");
589 Ok(())
590 }
591}
592
593#[cfg(all(test, target_os = "linux"))]
594mod retained_alias_tests {
595 use super::*;
596
597 #[test]
598 fn retained_output_leaf_alias_prevents_helper_release() -> anyhow::Result<()> {
599 let temporary = tempfile::tempdir()?;
600 let workspace = temporary.path().canonicalize()?;
601 let helper = super::super::session_info(&workspace, "retained").directory.join("helper");
602 std::fs::create_dir_all(
603 helper.parent().ok_or_else(|| anyhow::anyhow!("helper parent missing"))?,
604 )?;
605 std::fs::write(&helper, "retained bytes")?;
606 let (text, metadata) = read_managed(&workspace, "retained", &helper)?
607 .ok_or_else(|| anyhow::anyhow!("read metadata missing"))?;
608 let observed = TemporaryReleaseTarget {
609 file_path: helper.to_string_lossy().into_owned(),
610 expected_revision: revision(text.as_bytes()),
611 artifact_generation: metadata.artifact_generation,
612 };
613 let scratch = workspace.join(".winx/scratch");
614 std::fs::create_dir_all(&scratch)?;
615 let retained = scratch.join("bash-output-old.txt");
616 std::os::unix::fs::symlink(&helper, &retained)?;
617 let request = prepare(&workspace, "retained", &[observed])?;
618 assert!(
619 release(&request).is_err(),
620 "a real retained output path still promises these bytes"
621 );
622 assert_eq!(std::fs::read_to_string(retained)?, "retained bytes");
623 Ok(())
624 }
625}