1use 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
21pub const PAUSE_VAR: &str = "RK_STAGE_CLEAN_PAUSE_AFTER_VALIDATE";
25
26pub const PAUSE_BEFORE_OPEN_VAR: &str = "RK_STAGE_CLEAN_PAUSE_BEFORE_OPEN";
30
31pub const PAUSE_AFTER_QUARANTINE_VAR: &str = "RK_STAGE_CLEAN_PAUSE_AFTER_QUARANTINE";
37
38const QUARANTINE_PREFIX: &str = ".rk-clean-quarantine-";
40
41#[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 #[must_use]
55 pub fn resolved(&self) -> &Path {
56 &self.resolved
57 }
58
59 #[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
75pub 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
113fn 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
155fn 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#[derive(Debug, serde::Deserialize)]
189struct SchemaOnly {
190 schema: String,
191}
192
193#[derive(Debug, serde::Deserialize)]
197struct CleanReceipt {
198 stage_root: String,
199 target: String,
200}
201
202fn 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
294fn 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
312fn 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
328fn 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
339fn 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
347pub 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(¤t) != 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
413fn 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(¤t) != 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
484fn 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
493fn 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 #[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 #[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 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 #[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}