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}