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> {
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
112fn 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
154fn 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#[derive(Debug, serde::Deserialize)]
188struct SchemaOnly {
189 schema: String,
190}
191
192#[derive(Debug, serde::Deserialize)]
196struct CleanReceipt {
197 stage_root: String,
198 target: String,
199}
200
201fn 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
293fn 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
311fn 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
327fn 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
338fn 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
346pub 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(¤t) != 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
412fn 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(¤t) != 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
483fn 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
492fn 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 #[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/3","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 #[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/3","stage_root":"{}"}}"#,
560 candidate.display()
561 ),
562 format!(
563 r#"{{"schema":"rk.stage/3","stage_root":"{}","target":null}}"#,
564 candidate.display()
565 ),
566 format!(
567 r#"{{"schema":"rk.stage/3","stage_root":"{}","target":""}}"#,
568 candidate.display()
569 ),
570 format!(
571 r#"{{"schema":"rk.stage/3","stage_root":"{}","target":"repo"}}"#,
572 candidate.display()
573 ),
574 format!(
575 r#"{{"schema":"rk.stage/3","stage_root":"{}","target":"{}/../candidate/repo"}}"#,
576 candidate.display(),
577 candidate.display()
578 ),
579 format!(
580 r#"{{"schema":"rk.stage/3","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 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/3","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 #[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/3","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}