Skip to main content

spec_driven_docs/landing/
apply.rs

1//! Write one rendered candidate into a target, directly.
2//!
3//! Everything is decided before the first byte moves: every destination is
4//! checked for containment, every whole file this tool would own is checked
5//! for attribution, and the target lock is taken for the whole run. Then
6//! each destination is replaced in its own directory, and the record is
7//! written last.
8//!
9//! There is no journal and no rollback. A run that stops leaves whole
10//! files, the previous record, a visible Git diff, and a command that runs
11//! again. What it finished is reported, because that is what the operator
12//! needs and what the next run reconciles against.
13
14use 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/// What one landing did.
24#[derive(Debug, Default)]
25pub struct Outcome {
26    /// Every destination this run wrote, in the order it wrote them.
27    pub written: Vec<Utf8PathBuf>,
28    /// Every managed destination this release no longer owns, taken back.
29    pub removed: Vec<String>,
30}
31
32/// What the target's own record says this tool owns today.
33///
34/// It decides three things: which whole files may be refreshed, which
35/// marked regions may be re-spliced, and which files a release that
36/// stopped landing them may take back.
37#[derive(Debug, Default, Clone)]
38pub struct Recorded {
39    /// Each managed destination and the digest the record vouches for.
40    pub managed: Vec<(String, Sha256)>,
41    /// Each integration host and the hash of the region this tool owns.
42    pub integration: Vec<(String, Sha256)>,
43}
44
45/// Write the candidate into the target, and record it last.
46///
47/// The caller holds the target lock for the whole of this, including the
48/// observation it passes in: a candidate rendered before the lock would
49/// describe a target somebody else could still be changing.
50///
51/// # Errors
52///
53/// [`AppError::Refused`] where a destination escapes the target or holds
54/// bytes no record accounts for, [`AppError::Busy`] where another process
55/// holds the target, and I/O errors of the writes themselves.
56pub 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            // A reported rename failure is re-observed before it is
73            // believed. A remote filesystem may have completed the rename
74            // it reported as failed, and a report that called that
75            // destination unwritten would send the operator looking for
76            // bytes that are already there.
77            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        // Re-checked here, not only when the list was built: a component
88        // swapped since then is refused rather than followed, and bytes
89        // that changed since then are left alone. A removal is authorized
90        // by the bytes the record holds, never by the path alone.
91        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            // Gone already, or no longer the bytes the record vouches for.
100            _ => 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    // The record is last. Until it lands, the previous one describes the
113    // target, which is what the next run reads.
114    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
125/// Replace one destination in its own directory.
126///
127/// The chain is inspected again immediately before the rename, so a
128/// component swapped between the check and the write is refused rather
129/// than followed.
130fn 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    // Before the scratch file is created, so nothing is written through a
136    // component that already escapes.
137    if let Some(refusal) = refuse() {
138        return Err(refusal);
139    }
140    let path = target.join(destination);
141    let scratch = Stage::write(&path, bytes)?;
142    // And again before the rename, so a component swapped in between is
143    // refused rather than followed.
144    if let Some(refusal) = refuse() {
145        Stage::discard(&scratch);
146        return Err(refusal);
147    }
148    Stage::replace(&scratch, &path)
149}
150
151/// The refusal a run that stopped partway carries.
152fn 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
176/// Refuse any destination that does not resolve beneath the target.
177///
178/// Each component is inspected without being followed, so a link standing
179/// in the path cannot redirect a write outside the tree the operator named.
180fn 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            // One blocked directory holds many destinations, and naming it
186            // once is what an operator can act on.
187            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
205/// Why one destination does not resolve beneath the target, if it does not.
206fn 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(&current) else {
222            // Absent, so nothing below it exists to be redirected through.
223            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
238/// Refuse a whole file this tool would own whose bytes no record vouches
239/// for.
240///
241/// The record authorizes exact bytes, never a path. A destination whose
242/// contents are not the ones the record holds is one somebody edited or
243/// one somebody else wrote, and neither is this tool's to replace.
244/// Missing provenance routes to the agent, never to an automatic
245/// overwrite.
246fn 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            // A marked region sits in a file the project owns, so what the
267            // record vouches for is the region rather than the file. Every
268            // byte outside it is the project's and survives either way.
269            Ownership::Integration => region_vouched(destination, &held, recorded),
270            // An adopted destination is the project's from the moment it
271            // lands, and the projection already kept what is there.
272            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
288/// Whether the record vouches for the marked region a host file holds.
289///
290/// A host with no region yet is a first landing into a file the project
291/// wrote, which the record cannot have an entry for and which the splice
292/// leaves otherwise untouched.
293fn 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
318/// Remove the directories one removal emptied, inside the roots this tool
319/// owns.
320///
321/// A release that stops landing a whole package would otherwise leave its
322/// directory behind, and an empty directory reads as something the tool
323/// still owns.
324fn 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
340/// Every managed destination the record holds that this release no longer
341/// lands.
342///
343/// Only managed, and only under a root this tool owns. An adopted file a
344/// release stops seeding stays: the project owns it from the moment it
345/// lands, and a version moving is not permission to take it back. A file
346/// whose bytes are not the ones the record vouches for stays too, because
347/// somebody edited it and a removal would be a silent loss.
348fn 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        // A removal reached through a link would delete somebody else's
370        // file, so it refuses rather than following it.
371        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        // Nothing else landed: the refusal is before the first write.
465        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        // The record names the path and vouches for other bytes, which is
484        // what a local edit to a managed file looks like.
485        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        // The record vouches for exactly the bytes that are there, which is
516        // what an older landing of this tool leaves.
517        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}