Skip to main content

winx_code_agent/utils/agent_temp/
release.rs

1//! Exact observed-generation release of session-owned helpers. Runtime owners
2//! call `release` only while holding exclusive authoritative session admission.
3use 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    /// `ReadFiles` does not acquire the runtime owner's exclusive admission gate.
21    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    // HMAC-SHA256 with explicit length framing. The process secret makes a
156    // pathname/metadata guess insufficient, and intentionally expires on restart.
157    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/// A second bounded read for managed helpers ties content and generation to one
195/// no-follow file descriptor. Ordinary source reads retain their existing path.
196#[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    // Keep every inspected inode open through commit; reuse cannot impersonate
311    // an observed object between preflight and its atomic claim.
312    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                        // Rename changes ctime itself. Dev/inode stay bound to the
353                        // pinned preflight handle; bytes are checked again.
354                        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        // The no-follow hierarchy proves this canonicalization contains no
414        // symlink components under the supported runtime/file-tool boundary.
415        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/// Depth-first traversal retains only a bounded stack of open directories.
429/// Every leaf is inspected without following links; unknown protection retains
430/// all requested helpers. The guardian admission gate excludes managed shell
431/// topology mutations, and supported file tools cannot create symlink aliases.
432#[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()?; // DirEntry::metadata never follows a leaf symlink.
452        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}