Skip to main content

release_kit/stage/
clean.rs

1//! `rk stage clean`: remove one stage that names itself, and nothing else.
2//!
3//! The argument is resolved without following a final symlink, judged
4//! against the protected set, and then the parent directory and the
5//! stage directory are opened and held. Every removal goes through those
6//! descriptors, addressed as `/proc/self/fd/<fd>/<name>`, which resolves
7//! against the directory the descriptor holds and not against the path
8//! that was validated: a path swapped for a link after validation
9//! redirects no deletion. The crate forbids `unsafe`, so the descriptor
10//! walk uses the kernel's own link to an open directory instead of
11//! `openat` and `unlinkat` through FFI.
12
13use std::fs::{self, File};
14use std::path::{Path, PathBuf};
15
16use super::{RECEIPT_NAME, STAGE_SCHEMA};
17use crate::diagnostic::{Diagnostic, Reason};
18use crate::error::RkError;
19use crate::held::{self, Identity, open_dir, proc_path};
20
21/// The proof's seam: a directory where the run writes `validated` after
22/// it has opened the stage, then waits for `proceed` before it removes
23/// anything, so a test can swap the path in between.
24pub const PAUSE_VAR: &str = "RK_STAGE_CLEAN_PAUSE_AFTER_VALIDATE";
25
26/// The proof's second seam: the name of one child directory whose open
27/// waits, after its check, for `proceed-checked` under the pause
28/// directory, having written `checked` there.
29pub const PAUSE_BEFORE_OPEN_VAR: &str = "RK_STAGE_CLEAN_PAUSE_BEFORE_OPEN";
30
31/// The proof's third seam: the name of one entry whose removal waits.
32///
33/// A file, a child directory, or the stage root itself: after its
34/// quarantined identity has been judged, the run writes `quarantined`
35/// under the pause directory and waits for `proceed-quarantined` there.
36pub const PAUSE_AFTER_QUARANTINE_VAR: &str = "RK_STAGE_CLEAN_PAUSE_AFTER_QUARANTINE";
37
38/// The prefix of a quarantined entry's name inside the held directory.
39const QUARANTINE_PREFIX: &str = ".rk-clean-quarantine-";
40
41/// A stage validated and held open for removal.
42#[derive(Debug)]
43pub struct Validated {
44    parent: File,
45    stage: File,
46    stage_identity: Identity,
47    name: std::ffi::OsString,
48    resolved: PathBuf,
49    target: String,
50}
51
52impl Validated {
53    /// The canonical path the stage stands at.
54    #[must_use]
55    pub fn resolved(&self) -> &Path {
56        &self.resolved
57    }
58
59    /// The target the stage receipt names.
60    #[must_use]
61    pub fn target(&self) -> &str {
62        &self.target
63    }
64}
65
66fn refuse(message: String, expected: &str) -> RkError {
67    RkError::refusal(
68        Diagnostic::new(Reason::DestructiveRefusal, message)
69            .expected(expected)
70            .action("pass the path of one stage directory, as rk stage printed it")
71            .target_state("unchanged"),
72    )
73}
74
75/// Resolve `argument` without following a final symlink, refuse every
76/// protected or ambiguous path, and hold the stage and its parent open.
77///
78/// # Errors
79///
80/// Returns [`RkError::Missing`] for a path that does not exist, an
81/// `unsupported-schema` refusal for a receipt outside this binary's
82/// stage schema, and a
83/// `destructive-refusal` for the filesystem root, a home directory, a
84/// repository root, the receipt's target or any ancestor of it, a
85/// symlink, a directory without a receipt, and a receipt whose
86/// `stage_root` differs from the resolved argument.
87pub fn validate(argument: &Path) -> Result<Validated, RkError> {
88    let (parent, name, resolved) = resolve(argument)?;
89    let metadata = judge_entry(argument, &resolved)?;
90    let target = judge_receipt(&resolved)?;
91    let parent_dir = open_dir(&parent)?;
92    let stage = open_dir(&proc_path(&parent_dir).join(&name))?;
93    let stage_identity = Identity::of(&stage.metadata()?);
94    if stage_identity != Identity::of(&metadata) {
95        return Err(refuse(
96            format!(
97                "{} changed while it was being validated",
98                resolved.display()
99            ),
100            "a stage directory that stays put",
101        ));
102    }
103    Ok(Validated {
104        parent: parent_dir,
105        stage,
106        stage_identity,
107        name,
108        resolved,
109        target,
110    })
111}
112
113/// The canonical parent, the final name, and their join: the argument
114/// resolved without following its final component, with the filesystem
115/// root and the home directory refused by name.
116fn resolve(argument: &Path) -> Result<(PathBuf, std::ffi::OsString, PathBuf), RkError> {
117    let name = argument
118        .file_name()
119        .filter(|name| *name != "." && *name != "..")
120        .ok_or_else(|| {
121            refuse(
122                format!("{} names no directory to remove", argument.display()),
123                "the path of one stage directory",
124            )
125        })?
126        .to_owned();
127    let parent = argument
128        .parent()
129        .filter(|parent| !parent.as_os_str().is_empty())
130        .map_or_else(|| Path::new("."), |parent| parent);
131    let parent = fs::canonicalize(parent).map_err(|error| missing(argument, &error))?;
132    let resolved = parent.join(&name);
133    if resolved.parent().is_none() {
134        return Err(refuse(
135            "the filesystem root is never a stage".to_owned(),
136            "the path of one stage directory",
137        ));
138    }
139    if let Some(home) = std::env::var_os("HOME")
140        .filter(|home| !home.is_empty())
141        .and_then(|home| fs::canonicalize(home).ok())
142        && home == resolved
143    {
144        return Err(refuse(
145            format!(
146                "{} is the home directory, never a stage",
147                resolved.display()
148            ),
149            "the path of one stage directory",
150        ));
151    }
152    Ok((parent, name, resolved))
153}
154
155/// The entry at the resolved path: an existing directory that is neither
156/// a link nor a repository root.
157fn judge_entry(argument: &Path, resolved: &Path) -> Result<fs::Metadata, RkError> {
158    let metadata = match fs::symlink_metadata(resolved) {
159        Ok(metadata) => metadata,
160        Err(error) => return Err(missing(argument, &error)),
161    };
162    if metadata.file_type().is_symlink() {
163        return Err(refuse(
164            format!(
165                "{} is a symlink, and a link is never removed as a stage",
166                resolved.display()
167            ),
168            "the stage directory itself, not a link to it",
169        ));
170    }
171    if !metadata.is_dir() {
172        return Err(refuse(
173            format!("{} is not a directory", resolved.display()),
174            "the path of one stage directory",
175        ));
176    }
177    if fs::symlink_metadata(resolved.join(".git")).is_ok() {
178        return Err(refuse(
179            format!("{} is a repository root, never a stage", resolved.display()),
180            "a stage directory, which carries no .git",
181        ));
182    }
183    Ok(metadata)
184}
185
186/// The schema alone, read first so a receipt at another schema is named
187/// as such rather than as one missing this schema's fields.
188#[derive(Debug, serde::Deserialize)]
189struct SchemaOnly {
190    schema: String,
191}
192
193/// The two paths a cleanup reads from a receipt once its schema is
194/// known, each required: a receipt missing one is not a stage receipt
195/// this verb removes.
196#[derive(Debug, serde::Deserialize)]
197struct CleanReceipt {
198    stage_root: String,
199    target: String,
200}
201
202/// The receipt at the resolved path, judged: present, parsing as a typed
203/// receipt, at this binary's schema, naming the directory it sits in, and
204/// naming a canonical absolute target that is not the directory or below
205/// it. Returns the target it names.
206fn judge_receipt(resolved: &Path) -> Result<String, RkError> {
207    let receipt_path = resolved.join(RECEIPT_NAME);
208    let unparsed = |error: serde_json::Error| {
209        refuse(
210            format!(
211                "{} does not parse as a stage receipt: {error}",
212                receipt_path.display()
213            ),
214            "a directory rk stage wrote, whose stage.json carries schema, stage_root, and target",
215        )
216    };
217    let bytes = match fs::read(&receipt_path) {
218        Ok(bytes) => bytes,
219        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
220            return Err(refuse(
221                format!(
222                    "{} carries no {RECEIPT_NAME}, so it is not a stage",
223                    resolved.display()
224                ),
225                "a directory rk stage wrote",
226            ));
227        }
228        Err(error) => return Err(error.into()),
229    };
230    let declared: SchemaOnly = serde_json::from_slice(&bytes).map_err(unparsed)?;
231    if declared.schema != STAGE_SCHEMA {
232        return Err(RkError::refusal(
233            Diagnostic::new(
234                Reason::UnsupportedSchema,
235                format!(
236                    "{} declares schema {:?}, and this binary removes only {STAGE_SCHEMA}",
237                    receipt_path.display(),
238                    declared.schema
239                ),
240            )
241            .expected(format!("a receipt declaring {STAGE_SCHEMA}"))
242            .target_state("unchanged"),
243        ));
244    }
245    let receipt: CleanReceipt = serde_json::from_slice(&bytes).map_err(unparsed)?;
246    if Path::new(&receipt.stage_root) != resolved {
247        return Err(refuse(
248            format!(
249                "{} names {:?} as its stage root, not {}",
250                receipt_path.display(),
251                receipt.stage_root,
252                resolved.display()
253            ),
254            "a receipt whose stage_root is the directory it sits in",
255        ));
256    }
257    let target = Path::new(&receipt.target);
258    if !is_canonical_shape(target) {
259        return Err(refuse(
260            format!(
261                "{} names {:?} as its target, which is not a canonical absolute path",
262                receipt_path.display(),
263                receipt.target
264            ),
265            "a receipt whose target is the canonical absolute path rk stage recorded",
266        ));
267    }
268    if let Ok(canonical) = fs::canonicalize(target)
269        && canonical != target
270    {
271        return Err(refuse(
272            format!(
273                "{} names {:?} as its target, whose canonical path is {}",
274                receipt_path.display(),
275                receipt.target,
276                canonical.display()
277            ),
278            "a receipt whose target is the canonical absolute path rk stage recorded",
279        ));
280    }
281    if target.starts_with(resolved) {
282        return Err(refuse(
283            format!(
284                "{} is the target {} or an ancestor of it, never a stage",
285                resolved.display(),
286                receipt.target
287            ),
288            "a stage directory outside the target it describes",
289        ));
290    }
291    Ok(receipt.target)
292}
293
294/// Whether a recorded path has the shape a canonical path has: absolute,
295/// nonempty, and made of plain names alone.
296fn is_canonical_shape(path: &Path) -> bool {
297    use std::path::Component;
298    let mut components = path.components();
299    if components.next() != Some(Component::RootDir) {
300        return false;
301    }
302    let mut any = false;
303    for component in components {
304        if !matches!(component, Component::Normal(_)) {
305            return false;
306        }
307        any = true;
308    }
309    any
310}
311
312/// The absence of the argument, as the no-input failure.
313fn missing(argument: &Path, error: &std::io::Error) -> RkError {
314    if error.kind() == std::io::ErrorKind::NotFound {
315        RkError::missing(
316            Diagnostic::new(
317                Reason::TargetNotFound,
318                format!("{} does not exist", argument.display()),
319            )
320            .expected("the path of one stage directory, as rk stage printed it")
321            .target_state("unchanged"),
322        )
323    } else {
324        RkError::Io(std::io::Error::new(error.kind(), error.to_string()))
325    }
326}
327
328/// The failure for an entry that changed under the removal: the walk
329/// stops, nothing beyond the held descriptors was touched, and the run
330/// exits as an I/O failure naming where.
331fn swapped(path: &Path, detail: &str) -> std::io::Error {
332    swapped_leaving(
333        path,
334        detail,
335        "the removal stopped there, and nothing outside the validated stage was touched",
336    )
337}
338
339/// [`swapped`], stating what the run left behind in its own words.
340fn swapped_leaving(path: &Path, detail: &str, aftermath: &str) -> std::io::Error {
341    std::io::Error::other(format!(
342        "{} {detail} while the stage was being removed; {aftermath}",
343        path.display()
344    ))
345}
346
347/// Remove the validated stage through its held descriptors.
348///
349/// Every entry below it goes first, then the directory entry itself,
350/// quarantined under an unpredictable name inside its validated parent,
351/// judged against the validated identity, and removed only on a match.
352///
353/// # Errors
354///
355/// Any removal failure, and an I/O failure naming the entry where the
356/// tree changed under the removal: a child whose opened identity differs
357/// from the one checked, a name that vanished after validation, an entry
358/// that gained content after it was emptied, or a parent entry replaced
359/// while the stage was being emptied. In the last case the validated
360/// directory's contents are gone and the entry now under the quarantine
361/// name was left alone.
362pub fn remove(validated: &Validated) -> Result<(), RkError> {
363    held::pause(PAUSE_VAR, "validated", "proceed");
364    let shown = validated.resolved.as_path();
365    remove_contents(&validated.stage, shown)?;
366    let (quarantined, current) = match held::quarantine(
367        &validated.parent,
368        &validated.name,
369        QUARANTINE_PREFIX,
370    ) {
371        Ok(moved) => moved,
372        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
373            return Err(RkError::Io(swapped_leaving(
374                shown,
375                "vanished from its parent",
376                "the validated directory's contents were removed through the held descriptor, and nothing else was touched",
377            )));
378        }
379        Err(error) => return Err(error.into()),
380    };
381    if current.file_type().is_symlink()
382        || !current.is_dir()
383        || Identity::of(&current) != validated.stage_identity
384    {
385        return Err(RkError::Io(swapped_leaving(
386            shown,
387            "was replaced",
388            &format!(
389                "the validated directory's contents were removed, and the entry that stood at its path was moved to {} beside it and left alone",
390                quarantined.display()
391            ),
392        )));
393    }
394    pause_after_quarantine(&validated.name);
395    let entry = proc_path(&validated.parent).join(&quarantined);
396    fs::remove_dir(&entry).map_err(|error| {
397        if error.kind() == std::io::ErrorKind::DirectoryNotEmpty {
398            swapped_leaving(
399                shown,
400                "gained entries after it was emptied",
401                &format!(
402                    "the validated directory was moved to {} beside its path and left alone with what it gained",
403                    quarantined.display()
404                ),
405            )
406        } else {
407            error
408        }
409    })?;
410    Ok(())
411}
412
413/// Remove every entry below the open directory `dir`, addressing each by
414/// the descriptor and never by the validated path. The entries are listed
415/// once, up front, so an entry added while the walk runs is not removed.
416/// A directory child is opened and its descriptor's identity compared
417/// with the checked entry before the walk descends. Every removal goes
418/// through a quarantine: the entry is renamed to an unpredictable name
419/// inside the held directory, judged there against its checked identity,
420/// and removed only on a match. `shown` is the path the failure names
421/// for the operator.
422fn remove_contents(dir: &File, shown: &Path) -> std::io::Result<()> {
423    let base = proc_path(dir);
424    let vanished = |error: std::io::Error, name: &std::ffi::OsStr| {
425        if error.kind() == std::io::ErrorKind::NotFound {
426            swapped(&shown.join(name), "vanished")
427        } else {
428            error
429        }
430    };
431    let names: Vec<std::ffi::OsString> = fs::read_dir(&base)?
432        .map(|entry| entry.map(|entry| entry.file_name()))
433        .collect::<std::io::Result<_>>()?;
434    for name in names {
435        let path = base.join(&name);
436        let checked = fs::symlink_metadata(&path).map_err(|error| vanished(error, &name))?;
437        let expected = if checked.is_dir() {
438            pause_before_open(&name);
439            let sub = open_dir(&path).map_err(|error| vanished(error, &name))?;
440            let opened = Identity::of(&sub.metadata()?);
441            if opened != Identity::of(&checked) {
442                return Err(swapped(
443                    &shown.join(&name),
444                    "was exchanged for another directory",
445                ));
446            }
447            remove_contents(&sub, &shown.join(&name))?;
448            opened
449        } else {
450            Identity::of(&checked)
451        };
452        let (quarantined, current) = held::quarantine(dir, &name, QUARANTINE_PREFIX)
453            .map_err(|error| vanished(error, &name))?;
454        if current.is_dir() != checked.is_dir()
455            || current.file_type().is_symlink() != checked.file_type().is_symlink()
456            || Identity::of(&current) != expected
457        {
458            return Err(swapped_leaving(
459                &shown.join(&name),
460                "was exchanged for another entry",
461                &format!(
462                    "the entry that took its name was moved to {} inside the stage and left alone",
463                    quarantined.display()
464                ),
465            ));
466        }
467        pause_after_quarantine(&name);
468        let held_entry = base.join(&quarantined);
469        if current.is_dir() {
470            fs::remove_dir(&held_entry).map_err(|error| {
471                if error.kind() == std::io::ErrorKind::DirectoryNotEmpty {
472                    swapped(&shown.join(&name), "gained entries after it was emptied")
473                } else {
474                    error
475                }
476            })?;
477        } else {
478            fs::remove_file(&held_entry)?;
479        }
480    }
481    Ok(())
482}
483
484/// The second seam: pause between the check of a child directory named
485/// by [`PAUSE_BEFORE_OPEN_VAR`] and its open, so a test can exchange it
486/// in the one window the identity comparison exists for.
487fn pause_before_open(name: &std::ffi::OsStr) {
488    if std::env::var_os(PAUSE_BEFORE_OPEN_VAR).is_some_and(|wanted| wanted == name) {
489        held::pause(PAUSE_VAR, "checked", "proceed-checked");
490    }
491}
492
493/// The third seam: pause after the quarantined entry named by
494/// [`PAUSE_AFTER_QUARANTINE_VAR`] has been judged and before it goes, so
495/// a test can put something at its old name and prove that survives.
496fn pause_after_quarantine(name: &std::ffi::OsStr) {
497    if std::env::var_os(PAUSE_AFTER_QUARANTINE_VAR).is_some_and(|wanted| wanted == name) {
498        held::pause(PAUSE_VAR, "quarantined", "proceed-quarantined");
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::{STAGE_SCHEMA, remove, validate};
505    use crate::diagnostic::Reason;
506    use crate::error::RkError;
507
508    fn reason_of(error: &RkError) -> Reason {
509        error.reason()
510    }
511
512    /// A directory without a receipt, a receipt at another schema, and a
513    /// receipt naming another root all refuse, and none is touched.
514    #[test]
515    fn a_directory_that_does_not_name_itself_refuses() {
516        let scratch = tempfile::tempdir().expect("a scratch dir exists");
517        let bare = scratch.path().join("bare");
518        std::fs::create_dir(&bare).expect("creates");
519        assert_eq!(
520            reason_of(&validate(&bare).expect_err("refuses")),
521            Reason::DestructiveRefusal
522        );
523        let other = scratch.path().join("other");
524        std::fs::create_dir(&other).expect("creates");
525        std::fs::write(other.join("stage.json"), r#"{"schema":"rk.other/1"}"#).expect("writes");
526        assert_eq!(
527            reason_of(&validate(&other).expect_err("refuses")),
528            Reason::UnsupportedSchema
529        );
530        let moved = scratch.path().join("moved");
531        std::fs::create_dir(&moved).expect("creates");
532        std::fs::write(
533            moved.join("stage.json"),
534            format!(
535                r#"{{"schema":"{STAGE_SCHEMA}","stage_root":"{}","target":"/nowhere"}}"#,
536                other.display()
537            ),
538        )
539        .expect("writes");
540        assert_eq!(
541            reason_of(&validate(&moved).expect_err("refuses")),
542            Reason::DestructiveRefusal
543        );
544        assert!(bare.is_dir() && other.is_dir() && moved.is_dir());
545    }
546
547    /// A receipt lacking its target, naming a relative target, or naming a
548    /// non-canonical target refuses before anything opens, even where a
549    /// repository sits below the candidate path.
550    #[test]
551    fn a_malformed_receipt_refuses_before_anything_is_removed() {
552        let scratch = tempfile::tempdir().expect("a scratch dir exists");
553        let canonical = std::fs::canonicalize(scratch.path()).expect("canonical");
554        let candidate = canonical.join("candidate");
555        std::fs::create_dir_all(candidate.join("repo/.git")).expect("creates");
556        std::fs::write(candidate.join("repo/precious"), b"keep").expect("writes");
557        let receipt = candidate.join("stage.json");
558        for body in [
559            format!(
560                r#"{{"schema":"{STAGE_SCHEMA}","stage_root":"{}"}}"#,
561                candidate.display()
562            ),
563            format!(
564                r#"{{"schema":"{STAGE_SCHEMA}","stage_root":"{}","target":null}}"#,
565                candidate.display()
566            ),
567            format!(
568                r#"{{"schema":"{STAGE_SCHEMA}","stage_root":"{}","target":""}}"#,
569                candidate.display()
570            ),
571            format!(
572                r#"{{"schema":"{STAGE_SCHEMA}","stage_root":"{}","target":"repo"}}"#,
573                candidate.display()
574            ),
575            format!(
576                r#"{{"schema":"{STAGE_SCHEMA}","stage_root":"{}","target":"{}/../candidate/repo"}}"#,
577                candidate.display(),
578                candidate.display()
579            ),
580            format!(
581                r#"{{"schema":"{STAGE_SCHEMA}","stage_root":"{}","target":"{}/repo/"}}"#,
582                candidate.display(),
583                candidate.display()
584            ),
585        ] {
586            std::fs::write(&receipt, &body).expect("writes");
587            let error = validate(&candidate).expect_err("refuses");
588            assert_eq!(reason_of(&error), Reason::DestructiveRefusal, "{body}");
589            assert_eq!(
590                std::fs::read(candidate.join("repo/precious")).expect("reads"),
591                b"keep",
592                "{body}"
593            );
594        }
595        // A receipt whose target is a link to the real repository: its
596        // canonical path differs from what it names.
597        let link = canonical.join("link");
598        std::os::unix::fs::symlink(candidate.join("repo"), &link).expect("links");
599        std::fs::write(
600            &receipt,
601            format!(
602                r#"{{"schema":"{STAGE_SCHEMA}","stage_root":"{}","target":"{}"}}"#,
603                candidate.display(),
604                link.display()
605            ),
606        )
607        .expect("writes");
608        assert_eq!(
609            reason_of(&validate(&candidate).expect_err("refuses")),
610            Reason::DestructiveRefusal
611        );
612        assert!(candidate.join("repo/.git").is_dir());
613    }
614
615    /// A valid stage is removed through its descriptors, and its siblings
616    /// stand.
617    #[test]
618    fn a_valid_stage_is_removed_and_its_siblings_stand() {
619        let scratch = tempfile::tempdir().expect("a scratch dir exists");
620        let canonical = std::fs::canonicalize(scratch.path()).expect("canonical");
621        let stage = canonical.join("stage");
622        std::fs::create_dir_all(stage.join("artifacts/deep")).expect("creates");
623        std::fs::write(stage.join("artifacts/deep/file"), b"x").expect("writes");
624        std::fs::write(
625            stage.join("stage.json"),
626            format!(
627                r#"{{"schema":"{STAGE_SCHEMA}","stage_root":"{}","target":"/nowhere"}}"#,
628                stage.display()
629            ),
630        )
631        .expect("writes");
632        let sibling = canonical.join("sibling");
633        std::fs::write(&sibling, b"keep").expect("writes");
634        let validated = validate(&stage).expect("validates");
635        remove(&validated).expect("removes");
636        assert!(!stage.exists());
637        assert_eq!(std::fs::read(&sibling).expect("reads"), b"keep");
638    }
639}