Skip to main content

cordance_emit/
lib.rs

1//! Target emitters. Each emitter consumes the same `CordancePack` IR and
2//! writes a deterministic, fence-aware file.
3//!
4//! Every emitter:
5//! - Refuses to follow symlinks / Windows reparse points via
6//!   [`cordance_core::fs::safe_write_with_mkdir`] (ancestor walk included).
7//! - Merges fenced regions (`<!-- cordance:begin <key> -->`) so hand-edits
8//!   outside fences survive regeneration.
9//! - Sanitises target-controlled strings before fence interpolation so a
10//!   hostile `cordance.toml` can't inject fake fence markers.
11//!
12//! # Golden path
13//!
14//! ```no_run
15//! use camino::Utf8PathBuf;
16//! use cordance_core::advise::AdviseReport;
17//! use cordance_core::lock::SourceLock;
18//! use cordance_core::pack::{CordancePack, PackTargets, ProjectIdentity};
19//! use cordance_core::schema;
20//! use cordance_emit::{TargetEmitter, agents_md::AgentsMdEmitter};
21//!
22//! let repo_root = Utf8PathBuf::from(".");
23//! let pack = CordancePack {
24//!     schema: schema::CORDANCE_PACK_V1.into(),
25//!     project: ProjectIdentity {
26//!         name: "my-project".into(),
27//!         repo_root: repo_root.clone(),
28//!         kind: "rust-workspace".into(),
29//!         host_os: "linux".into(),
30//!         axiom_pin: None,
31//!     },
32//!     sources: vec![],
33//!     doctrine_pins: vec![],
34//!     targets: PackTargets::all(),
35//!     outputs: vec![],
36//!     source_lock: SourceLock::empty(),
37//!     advise: AdviseReport::empty(),
38//!     residual_risk: vec!["claim_ceiling=candidate".into()],
39//! };
40//!
41//! let outputs = AgentsMdEmitter.emit(&pack, &repo_root).expect("emit AGENTS.md");
42//! for out in &outputs {
43//!     println!("wrote {} ({} bytes)", out.path, out.bytes);
44//! }
45//! ```
46
47#![forbid(unsafe_code)]
48#![deny(clippy::unwrap_used, clippy::expect_used)]
49#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
50
51use camino::Utf8PathBuf;
52use cordance_core::pack::{CordancePack, PackOutput};
53use sha2::{Digest, Sha256};
54
55pub mod agents_md;
56pub mod claude_md;
57pub mod codex;
58pub mod cursor;
59pub mod evidence_map;
60pub mod harness_target;
61pub mod pack_json;
62
63// The fence-syntax sanitiser now lives in `cordance-core::fence` next to
64// `find_regions` / `replace_regions` — it is the same fence-syntax concern,
65// not an emit-specific one. Emitters reference it directly as
66// `cordance_core::fence::sanitise_fenced_value`. (Round-3 MEDIUM.)
67
68#[derive(Debug, thiserror::Error)]
69pub enum EmitError {
70    #[error("io error: {0}")]
71    Io(#[from] std::io::Error),
72    #[error("serialisation error: {0}")]
73    Serde(#[from] serde_json::Error),
74    #[error("fence error: {0}")]
75    Fence(cordance_core::fence::FenceError),
76}
77
78impl From<cordance_core::fence::FenceError> for EmitError {
79    fn from(e: cordance_core::fence::FenceError) -> Self {
80        Self::Fence(e)
81    }
82}
83
84/// Trait every target emitter implements.
85pub trait TargetEmitter {
86    /// Stable name of this target ("claude-code", "cursor", "codex", "axiom-harness-target").
87    fn name(&self) -> &'static str;
88
89    /// Render all outputs for this target.
90    /// Returns (repo-relative path, content bytes) pairs.
91    ///
92    /// # Errors
93    ///
94    /// Returns `Err` if rendering fails (serialisation, fence parse, etc.).
95    fn render(&self, pack: &CordancePack) -> Result<Vec<(Utf8PathBuf, Vec<u8>)>, EmitError>;
96
97    /// Plan: compute the bytes the emitter *would* write, return `PackOutput`
98    /// metadata (sha256, byte count). Pure — no filesystem mutation.
99    ///
100    /// `repo_root` is needed because the bytes a fence-bearing emitter
101    /// would write are NOT simply the rendered bytes — they're the result
102    /// of merging the rendered fenced regions into whatever non-fenced
103    /// content already lives in the on-disk file. Round-4 bughunt #3:
104    /// before this change, `plan` hashed the rendered bytes (no merge) while
105    /// `emit` hashed the merged bytes. `cordance check` then rescanned via
106    /// `plan` and saw drift on every fenced output that had any user-owned
107    /// byte outside a fence. The merge logic is now shared via
108    /// [`merge_for_emit`] and both `plan` and `emit` agree on the final
109    /// `final_bytes`.
110    ///
111    /// # Errors
112    ///
113    /// Returns `Err` if rendering, UTF-8 validation, or fence parsing fails.
114    fn plan(
115        &self,
116        pack: &CordancePack,
117        repo_root: &Utf8PathBuf,
118    ) -> Result<Vec<PackOutput>, EmitError> {
119        let rendered = self.render(pack)?;
120        rendered
121            .into_iter()
122            .map(|(rel_path, bytes)| {
123                let abs_path = repo_root.join(&rel_path);
124                let final_bytes = merge_for_emit(&rel_path, bytes, abs_path.as_std_path())?;
125                let sha256 = hex::encode(Sha256::digest(&final_bytes));
126                Ok(PackOutput {
127                    path: rel_path,
128                    target: self.name().to_string(),
129                    sha256,
130                    bytes: final_bytes.len() as u64,
131                    managed: true,
132                    source_anchors: vec![],
133                })
134            })
135            .collect()
136    }
137
138    /// Write all rendered outputs into `repo_root`.
139    ///
140    /// # Errors
141    ///
142    /// Returns `Err` if any I/O or render step fails.
143    ///
144    /// # Merge semantics
145    ///
146    /// Delegates to [`merge_for_emit`], which encodes:
147    /// - If `abs_path` does not exist: write the rendered bytes verbatim.
148    /// - If the rendered content contains no fenced regions: overwrite the
149    ///   target verbatim regardless of any stale fence markers on disk.
150    /// - If the rendered content contains fenced regions and every region's
151    ///   key is present in the existing file: do a batch fence replace,
152    ///   preserving content outside the fences.
153    /// - If the rendered content has any fence key that does not exist in the
154    ///   existing file (key drift): log a warning and replace the file
155    ///   verbatim. This is the "fail loud, do not silently drop content"
156    ///   branch.
157    fn emit(
158        &self,
159        pack: &CordancePack,
160        repo_root: &Utf8PathBuf,
161    ) -> Result<Vec<PackOutput>, EmitError> {
162        let rendered = self.render(pack)?;
163        let mut outputs = Vec::new();
164        for (rel_path, bytes) in rendered {
165            let abs_path = repo_root.join(&rel_path);
166
167            let final_bytes = merge_for_emit(&rel_path, bytes, abs_path.as_std_path())?;
168
169            // Round-5 redteam #1: a hostile target can plant the destination
170            // as a symlink (POSIX) or reparse point (Windows junction /
171            // symlink-file) pointing at any operator-owned file the process
172            // can write to (`~/.ssh/authorized_keys`, `~/.bashrc`, …). Route
173            // through `cordance_core::fs::safe_write_with_mkdir` so the
174            // helper refuses to follow the link. The operator can remove the
175            // link manually and re-run.
176            cordance_core::fs::safe_write_with_mkdir(abs_path.as_std_path(), &final_bytes)
177                .map_err(EmitError::Io)?;
178            let sha256 = hex::encode(Sha256::digest(&final_bytes));
179            outputs.push(PackOutput {
180                path: rel_path,
181                target: self.name().to_string(),
182                sha256,
183                bytes: final_bytes.len() as u64,
184                managed: true,
185                source_anchors: vec![],
186            });
187        }
188        Ok(outputs)
189    }
190}
191
192/// Compute the bytes a fence-bearing emitter would write to `abs_path`.
193///
194/// Encodes the three-branch merge strategy described on
195/// [`TargetEmitter::emit`]:
196///   1. file absent → rendered bytes verbatim.
197///   2. rendered content carries no fenced regions → rendered bytes verbatim.
198///   3. fenced regions present and every key is also present in the existing
199///      file → batch `replace_regions` of the existing file (preserves
200///      non-fenced bytes the user owns).
201///   4. fenced regions present but at least one key is missing on disk →
202///      `tracing::warn!` and overwrite verbatim. This is the "fail loud, do
203///      not silently drop content" branch.
204///
205/// Both [`TargetEmitter::plan`] and [`TargetEmitter::emit`] call this so the
206/// sha256 a plan reports matches the sha256 an emit writes. Round-4
207/// bughunt #3.
208///
209/// # Errors
210///
211/// Returns `EmitError::Io` if the existing file can't be read, or
212/// `EmitError::Fence` if either the rendered content or the existing file
213/// has unparseable fences.
214fn merge_for_emit(
215    rel_path: &Utf8PathBuf,
216    rendered: Vec<u8>,
217    abs_path: &std::path::Path,
218) -> Result<Vec<u8>, EmitError> {
219    // Non-UTF-8 emitter output is a bug, not a recoverable situation; fail
220    // loudly instead of silently corrupting with `from_utf8_lossy`.
221    let content_str = std::str::from_utf8(&rendered)
222        .map_err(|e| EmitError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
223    let new_regions = cordance_core::fence::find_regions(content_str)?;
224
225    cordance_core::fs::precheck_no_reparse_point_ancestor(abs_path).map_err(EmitError::Io)?;
226
227    if !abs_path.exists() {
228        return Ok(rendered);
229    }
230    if new_regions.is_empty() {
231        return Ok(rendered);
232    }
233
234    let existing = std::fs::read_to_string(abs_path).map_err(EmitError::Io)?;
235    let existing_regions = cordance_core::fence::find_regions(&existing)?;
236    let existing_keys: std::collections::HashSet<&str> =
237        existing_regions.iter().map(|r| r.key.as_str()).collect();
238
239    let any_new_key_missing = new_regions
240        .iter()
241        .any(|r| !existing_keys.contains(r.key.as_str()));
242
243    if any_new_key_missing {
244        let new_keys: Vec<&str> = new_regions.iter().map(|r| r.key.as_str()).collect();
245        let existing_keys_vec: Vec<&str> =
246            existing_regions.iter().map(|r| r.key.as_str()).collect();
247        tracing::warn!(
248            path = %rel_path,
249            new_keys = ?new_keys,
250            existing_keys = ?existing_keys_vec,
251            "fence key drift; replacing file verbatim instead of merging"
252        );
253        return Ok(rendered);
254    }
255
256    // All new keys exist in existing — do a batch fence replace.
257    let pairs: Vec<(&str, &str)> = new_regions
258        .iter()
259        .map(|r| (r.key.as_str(), r.body.as_str()))
260        .collect();
261    Ok(cordance_core::fence::replace_regions(&existing, &pairs).into_bytes())
262}
263
264#[cfg(test)]
265mod tests {
266    // Per-emitter tests live in their own modules and in tests/.
267}