1use camino::{Utf8Path, Utf8PathBuf};
15
16use crate::candidate::{Candidate, Ownership};
17use crate::domain::manifest::MANIFEST_PATH;
18use crate::domain::ownership::Sha256;
19use crate::domain::paths::PRUNABLE_ROOTS;
20use crate::error::AppError;
21use crate::transaction::stage::Stage;
22
23#[derive(Debug, Default)]
25pub struct Outcome {
26 pub written: Vec<Utf8PathBuf>,
28 pub removed: Vec<String>,
30}
31
32#[derive(Debug, Default, Clone)]
38pub struct Recorded {
39 pub managed: Vec<(String, Sha256)>,
41 pub integration: Vec<(String, Sha256)>,
43}
44
45pub fn land(
57 target: &Utf8Path,
58 candidate: &Candidate,
59 recorded: &Recorded,
60) -> Result<Outcome, AppError> {
61 contained(target, candidate)?;
62 unattributed(target, candidate, recorded)?;
63 let retired = retired(target, candidate, &recorded.managed)?;
64 let mut outcome = Outcome::default();
65
66 for destination in &candidate.destinations {
67 let path = target.join(&destination.path);
68 if std::fs::read(&path).is_ok_and(|held| held == destination.bytes) {
69 continue;
70 }
71 if let Err(error) = write_one(target, &destination.path, &destination.bytes) {
72 if std::fs::read(&path).is_ok_and(|held| held == destination.bytes) {
78 outcome.written.push(destination.path.clone());
79 }
80 return Err(stopped(&error, &outcome));
81 }
82 outcome.written.push(destination.path.clone());
83 }
84
85 for (destination, vouched) in retired {
86 let path = target.join(&destination);
87 if let Some((_, kind)) = escapes(target, Utf8Path::new(&destination)) {
92 return Err(stopped(
93 &AppError::Refused(format!("destination {kind}: {destination}")),
94 &outcome,
95 ));
96 }
97 match std::fs::read(&path) {
98 Ok(held) if Sha256::of(&held) == vouched => {}
99 _ => continue,
101 }
102 match std::fs::remove_file(&path) {
103 Ok(()) => {
104 prune_empty(target, &destination);
105 outcome.removed.push(destination);
106 }
107 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
108 Err(source) => return Err(stopped(&AppError::Io(source), &outcome)),
109 }
110 }
111
112 let record = candidate.manifest.to_json().into_bytes();
115 if let Err(error) = write_one(target, Utf8Path::new(MANIFEST_PATH), &record) {
116 if std::fs::read(target.join(MANIFEST_PATH)).is_ok_and(|held| held == record) {
117 outcome.written.push(Utf8PathBuf::from(MANIFEST_PATH));
118 }
119 return Err(stopped(&error, &outcome));
120 }
121 outcome.written.push(Utf8PathBuf::from(MANIFEST_PATH));
122 Ok(outcome)
123}
124
125fn write_one(target: &Utf8Path, destination: &Utf8Path, bytes: &[u8]) -> Result<(), AppError> {
131 let refuse = || {
132 escapes(target, destination)
133 .map(|(component, kind)| AppError::Refused(format!("{component} {kind}")))
134 };
135 if let Some(refusal) = refuse() {
138 return Err(refusal);
139 }
140 let path = target.join(destination);
141 let scratch = Stage::write(&path, bytes)?;
142 if let Some(refusal) = refuse() {
145 Stage::discard(&scratch);
146 return Err(refusal);
147 }
148 Stage::replace(&scratch, &path)
149}
150
151fn stopped(cause: &AppError, outcome: &Outcome) -> AppError {
153 let mut finished: Vec<String> = Vec::new();
154 if !outcome.written.is_empty() {
155 let done: Vec<String> = outcome.written.iter().map(ToString::to_string).collect();
156 finished.push(format!(
157 "these destinations hold candidate bytes: {}",
158 done.join(", ")
159 ));
160 }
161 if !outcome.removed.is_empty() {
162 finished.push(format!(
163 "these destinations were removed: {}",
164 outcome.removed.join(", ")
165 ));
166 }
167 if finished.is_empty() {
168 finished.push("nothing was written or removed".to_string());
169 }
170 AppError::Refused(format!(
171 "the landing stopped: {cause}; {}, the previous record still stands, and running this again finishes the rest",
172 finished.join("; ")
173 ))
174}
175
176fn contained(target: &Utf8Path, candidate: &Candidate) -> Result<(), AppError> {
181 let mut escaping: Vec<String> = Vec::new();
182 let mut note = |found: Option<(Utf8PathBuf, &'static str)>| {
183 if let Some((component, kind)) = found {
184 let reason = format!("{component} {kind}");
185 if !escaping.contains(&reason) {
188 escaping.push(reason);
189 }
190 }
191 };
192 for destination in &candidate.destinations {
193 note(escapes(target, &destination.path));
194 }
195 note(escapes(target, Utf8Path::new(MANIFEST_PATH)));
196 if escaping.is_empty() {
197 return Ok(());
198 }
199 Err(AppError::Refused(format!(
200 "the landing writes nothing: {}",
201 escaping.join("; ")
202 )))
203}
204
205fn escapes(target: &Utf8Path, destination: &Utf8Path) -> Option<(Utf8PathBuf, &'static str)> {
207 if destination.is_absolute() {
208 return Some((destination.to_owned(), "is absolute"));
209 }
210 if destination
211 .components()
212 .any(|part| part.as_str() == ".." || part.as_str() == ".")
213 {
214 return Some((destination.to_owned(), "climbs out of the target"));
215 }
216 let mut current = target.to_owned();
217 let components: Vec<&str> = destination.as_str().split('/').collect();
218 let last = components.len().saturating_sub(1);
219 for (index, part) in components.iter().enumerate() {
220 current = current.join(part);
221 let Ok(held) = std::fs::symlink_metadata(¤t) else {
222 return None;
224 };
225 if held.file_type().is_symlink() {
226 return Some((current, "escapes the target through a symlink"));
227 }
228 if index < last && !held.is_dir() {
229 return Some((current, "is not a directory"));
230 }
231 if index == last && !held.is_file() {
232 return Some((current, "is not a regular file"));
233 }
234 }
235 None
236}
237
238fn unattributed(
247 target: &Utf8Path,
248 candidate: &Candidate,
249 recorded: &Recorded,
250) -> Result<(), AppError> {
251 let mut collisions: Vec<String> = Vec::new();
252 for destination in &candidate.destinations {
253 let path = target.join(&destination.path);
254 let Ok(held) = std::fs::read(&path) else {
255 continue;
256 };
257 if held == destination.bytes {
258 continue;
259 }
260 let vouched = match destination.ownership {
261 Ownership::Managed => recorded
262 .managed
263 .iter()
264 .find(|(name, _)| name == destination.path.as_str())
265 .is_some_and(|(_, digest)| digest == &Sha256::of(&held)),
266 Ownership::Integration => region_vouched(destination, &held, recorded),
270 Ownership::Adopted => true,
273 };
274 if vouched {
275 continue;
276 }
277 collisions.push(destination.path.to_string());
278 }
279 if collisions.is_empty() {
280 return Ok(());
281 }
282 Err(AppError::Refused(format!(
283 "destinations hold bytes no record vouches for: {}; move them aside, or let the setup skill reconcile them",
284 collisions.join(", ")
285 )))
286}
287
288fn region_vouched(
294 destination: &crate::candidate::Destination,
295 held: &[u8],
296 recorded: &Recorded,
297) -> bool {
298 use crate::domain::marker;
299
300 let Ok(text) = std::str::from_utf8(held) else {
301 return false;
302 };
303 let hash = if destination.path == crate::domain::paths::HOOKS_CONFIG_PATH {
304 marker::block_hash(text)
305 } else {
306 marker::block_hash_with(text, marker::AGENTS_BEGIN, marker::AGENTS_END)
307 };
308 let Some(hash) = hash else {
309 return true;
310 };
311 recorded
312 .integration
313 .iter()
314 .find(|(name, _)| name == destination.path.as_str())
315 .is_some_and(|(_, recorded)| recorded == &hash)
316}
317
318fn prune_empty(target: &Utf8Path, destination: &str) {
325 let mut parent = Utf8Path::new(destination).parent();
326 while let Some(directory) = parent {
327 if !PRUNABLE_ROOTS
328 .iter()
329 .any(|prefix| format!("{directory}/").starts_with(prefix))
330 {
331 return;
332 }
333 if std::fs::remove_dir(target.join(directory)).is_err() {
334 return;
335 }
336 parent = directory.parent();
337 }
338}
339
340fn retired(
349 target: &Utf8Path,
350 candidate: &Candidate,
351 recorded_managed: &[(String, Sha256)],
352) -> Result<Vec<(String, Sha256)>, AppError> {
353 let landing: Vec<&str> = candidate
354 .destinations
355 .iter()
356 .map(|destination| destination.path.as_str())
357 .collect();
358 let mut retired = Vec::new();
359 for (destination, recorded) in recorded_managed {
360 if landing.contains(&destination.as_str()) {
361 continue;
362 }
363 if !PRUNABLE_ROOTS
364 .iter()
365 .any(|prefix| destination.starts_with(prefix))
366 {
367 continue;
368 }
369 if let Some((_, kind)) = escapes(target, Utf8Path::new(destination)) {
372 return Err(AppError::Refused(format!(
373 "destination {kind}: {destination}"
374 )));
375 }
376 let Ok(held) = std::fs::read(target.join(destination)) else {
377 continue;
378 };
379 if &Sha256::of(&held) != recorded {
380 continue;
381 }
382 retired.push((destination.clone(), recorded.clone()));
383 }
384 Ok(retired)
385}
386
387#[cfg(test)]
388mod tests {
389 #![allow(
390 clippy::unwrap_used,
391 reason = "a test panics as its failure signal, not as control flow"
392 )]
393
394 use super::*;
395 use crate::candidate::{Destination, Input, Placement, project};
396 use crate::domain::profile::ProfileId;
397 use crate::domain::version::CanonVersion;
398
399 fn target(dir: &tempfile::TempDir) -> Utf8PathBuf {
400 Utf8PathBuf::from(dir.path().to_str().unwrap())
401 }
402
403 fn candidate() -> Candidate {
404 project(&Input {
405 profile: ProfileId::Codebase,
406 version: CanonVersion::current(),
407 installed_at: "2026-01-01T00:00:00Z".to_string(),
408 docs_scratch: None,
409 reserve: Vec::new(),
410 writing_style: None,
411 evidence: crate::candidate::Evidence::default(),
412 })
413 .unwrap()
414 }
415
416 #[test]
417 fn a_landing_writes_every_destination_and_the_record_last() {
418 let dir = tempfile::tempdir().unwrap();
419 let target = target(&dir);
420 let candidate = candidate();
421
422 let outcome = land(&target, &candidate, &Recorded::default()).unwrap();
423
424 assert_eq!(
425 outcome.written.last().unwrap(),
426 &Utf8PathBuf::from(MANIFEST_PATH)
427 );
428 for destination in &candidate.destinations {
429 assert_eq!(
430 std::fs::read(target.join(&destination.path)).unwrap(),
431 destination.bytes,
432 "{}",
433 destination.path
434 );
435 }
436 }
437
438 #[test]
439 fn a_second_landing_writes_nothing_and_still_records() {
440 let dir = tempfile::tempdir().unwrap();
441 let target = target(&dir);
442 let candidate = candidate();
443 land(&target, &candidate, &Recorded::default()).unwrap();
444
445 let outcome = land(&target, &candidate, &Recorded::default()).unwrap();
446 assert_eq!(outcome.written, vec![Utf8PathBuf::from(MANIFEST_PATH)]);
447 }
448
449 #[test]
450 fn a_managed_destination_no_record_accounts_for_refuses_before_any_write() {
451 let dir = tempfile::tempdir().unwrap();
452 let target = target(&dir);
453 let candidate = candidate();
454 let managed = candidate
455 .destinations
456 .iter()
457 .find(|destination| destination.ownership == Ownership::Managed)
458 .unwrap();
459 crate::adapters::fs::write_file(&target.join(&managed.path), b"somebody else wrote this")
460 .unwrap();
461
462 let error = land(&target, &candidate, &Recorded::default()).unwrap_err();
463 assert!(error.to_string().contains(managed.path.as_str()), "{error}");
464 assert_eq!(
466 std::fs::read(target.join(&managed.path)).unwrap(),
467 b"somebody else wrote this"
468 );
469 assert!(!target.join(MANIFEST_PATH).exists());
470 }
471
472 #[test]
473 fn a_managed_destination_edited_since_the_record_refuses() {
474 let dir = tempfile::tempdir().unwrap();
475 let target = target(&dir);
476 let candidate = candidate();
477 let managed = candidate
478 .destinations
479 .iter()
480 .find(|destination| destination.ownership == Ownership::Managed)
481 .unwrap()
482 .clone();
483 crate::adapters::fs::write_file(&target.join(&managed.path), b"edited since").unwrap();
486 let recorded = Recorded {
487 managed: vec![(
488 managed.path.to_string(),
489 Sha256::of(b"what the record holds"),
490 )],
491 integration: Vec::new(),
492 };
493
494 let error = land(&target, &candidate, &recorded).unwrap_err();
495 assert!(error.to_string().contains(managed.path.as_str()), "{error}");
496 assert_eq!(
497 std::fs::read(target.join(&managed.path)).unwrap(),
498 b"edited since"
499 );
500 assert!(!target.join(MANIFEST_PATH).exists());
501 }
502
503 #[test]
504 fn a_recorded_managed_destination_is_refreshed() {
505 let dir = tempfile::tempdir().unwrap();
506 let target = target(&dir);
507 let candidate = candidate();
508 let managed = candidate
509 .destinations
510 .iter()
511 .find(|destination| destination.ownership == Ownership::Managed)
512 .unwrap()
513 .clone();
514 crate::adapters::fs::write_file(&target.join(&managed.path), b"older").unwrap();
515 let recorded = Recorded {
518 managed: vec![(managed.path.to_string(), Sha256::of(b"older"))],
519 integration: Vec::new(),
520 };
521
522 land(&target, &candidate, &recorded).unwrap();
523 assert_eq!(
524 std::fs::read(target.join(&managed.path)).unwrap(),
525 managed.bytes
526 );
527 }
528
529 #[test]
530 fn a_managed_file_this_release_dropped_is_taken_back() {
531 let dir = tempfile::tempdir().unwrap();
532 let target = target(&dir);
533 let dropped = ".spec-driven-docs/markdownlint/retired.jsonc";
534 crate::adapters::fs::write_file(&target.join(dropped), b"old").unwrap();
535 let recorded = Recorded {
536 managed: vec![(dropped.to_string(), Sha256::of(b"old"))],
537 integration: Vec::new(),
538 };
539
540 let outcome = land(&target, &candidate(), &recorded).unwrap();
541 assert_eq!(outcome.removed, vec![dropped.to_string()]);
542 assert!(!target.join(dropped).exists());
543 }
544
545 #[test]
546 fn an_edited_file_this_release_dropped_stays() {
547 let dir = tempfile::tempdir().unwrap();
548 let target = target(&dir);
549 let dropped = ".spec-driven-docs/markdownlint/retired.jsonc";
550 crate::adapters::fs::write_file(&target.join(dropped), b"edited since").unwrap();
551 let recorded = Recorded {
552 managed: vec![(dropped.to_string(), Sha256::of(b"old"))],
553 integration: Vec::new(),
554 };
555
556 let outcome = land(&target, &candidate(), &recorded).unwrap();
557 assert!(outcome.removed.is_empty());
558 assert!(target.join(dropped).exists());
559 }
560
561 #[test]
562 fn a_destination_reached_through_a_link_refuses() {
563 let dir = tempfile::tempdir().unwrap();
564 let target = target(&dir);
565 let outside = target.join("outside");
566 std::fs::create_dir_all(&outside).unwrap();
567 std::fs::create_dir_all(target.join(".spec-driven-docs")).unwrap();
568 std::os::unix::fs::symlink(
569 outside.as_std_path(),
570 target.join(".spec-driven-docs/markdownlint").as_std_path(),
571 )
572 .unwrap();
573
574 let error = land(&target, &candidate(), &Recorded::default()).unwrap_err();
575 assert!(error.to_string().contains("symlink"), "{error}");
576 assert!(!target.join(MANIFEST_PATH).exists());
577 }
578
579 #[test]
580 fn a_destination_that_climbs_out_refuses() {
581 let dir = tempfile::tempdir().unwrap();
582 let target = target(&dir);
583 let mut candidate = candidate();
584 candidate.destinations.push(Destination {
585 path: Utf8PathBuf::from("../escaped.md"),
586 bytes: b"x".to_vec(),
587 ownership: Ownership::Managed,
588 placement: Placement::WholeFile,
589 source: None,
590 });
591
592 let error = land(&target, &candidate, &Recorded::default()).unwrap_err();
593 assert!(error.to_string().contains("climbs out"), "{error}");
594 }
595}