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