Skip to content

Commit 8aa7b09

Browse files
committed
[anneal][v2] Add DiagnosticMapper to map compiler errors back to Rust source code
TAG=agy gherrit-pr-id: Gzq7escrh5c2qcrgidomnfaxbsi6xn7yu
1 parent 2c3aade commit 8aa7b09

1 file changed

Lines changed: 271 additions & 0 deletions

File tree

anneal/v2/src/diagnostics.rs

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
// Copyright 2026 The Fuchsia Authors
2+
//
3+
// Licensed under the 2-Clause BSD License <LICENSE-BSD or
4+
// https://opensource.org/license/bsd-2-clause>, Apache License, Version 2.0
5+
// <LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0>, or the MIT
6+
// license <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your option.
7+
// This file may not be copied, modified, or distributed except according to
8+
// those terms.
9+
10+
// Handling of compiler diagnostics and source mapping.
11+
//
12+
// This module provides the [`crate::diagnostics::DiagnosticMapper`] struct, which is responsible
13+
// for translating diagnostics from external tools, such as Charon, back to
14+
// annotated Rust source code. It maps errors in generated intermediate files
15+
// back to their origin spans in the user's codebase.
16+
17+
pub use cargo_metadata::diagnostic::{Diagnostic, DiagnosticLevel, DiagnosticSpan};
18+
19+
/// Maps diagnostics from generated intermediate code back to Rust source code.
20+
///
21+
/// The Charon compiler has no knowledge of the Rust workspace layout that
22+
/// orchestrated its execution. It reports diagnostics against generated or
23+
/// rewritten compilation inputs.
24+
///
25+
/// This mapper cross-references the compiler's emitted byte spans to
26+
/// annotations in Rust source code, dynamically synthesizing a
27+
/// [`miette::NamedSource`] that points into the user's `.rs` workspace files.
28+
pub struct DiagnosticMapper {
29+
user_root: std::path::PathBuf,
30+
user_root_canonical: std::path::PathBuf,
31+
source_cache: std::collections::HashMap<std::path::PathBuf, String>,
32+
}
33+
34+
#[derive(thiserror::Error, Debug)]
35+
#[error("{message}")]
36+
struct MappedError {
37+
message: String,
38+
src: miette::NamedSource<String>,
39+
labels: Vec<miette::LabeledSpan>,
40+
help: Option<String>,
41+
related: Vec<MappedError>,
42+
severity: Option<miette::Severity>,
43+
}
44+
45+
impl miette::Diagnostic for MappedError {
46+
fn source_code(&self) -> Option<&dyn miette::SourceCode> {
47+
Some(&self.src)
48+
}
49+
50+
fn labels(&self) -> Option<Box<dyn std::iter::Iterator<Item = miette::LabeledSpan> + '_>> {
51+
if self.labels.is_empty() { None } else { Some(Box::new(self.labels.iter().cloned())) }
52+
}
53+
54+
fn help(&self) -> Option<Box<dyn std::fmt::Display + '_>> {
55+
self.help.as_ref().map(|h| Box::new(h.clone()) as Box<dyn std::fmt::Display>)
56+
}
57+
58+
fn related<'a>(
59+
&'a self,
60+
) -> Option<Box<dyn std::iter::Iterator<Item = &'a dyn miette::Diagnostic> + 'a>> {
61+
if self.related.is_empty() {
62+
None
63+
} else {
64+
let iter = self.related.iter().map(|e| e as &dyn miette::Diagnostic);
65+
Some(Box::new(iter))
66+
}
67+
}
68+
69+
fn severity(&self) -> Option<miette::Severity> {
70+
self.severity
71+
}
72+
}
73+
74+
impl DiagnosticMapper {
75+
/// Creates a new mapper rooted at `user_root`.
76+
pub fn new(user_root: std::path::PathBuf) -> Self {
77+
let user_root_canonical =
78+
std::fs::canonicalize(&user_root).unwrap_or_else(|_| user_root.clone());
79+
Self { user_root, user_root_canonical, source_cache: std::collections::HashMap::new() }
80+
}
81+
82+
/// Resolves a path relative to the user root, if applicable.
83+
///
84+
/// This ensures we only report diagnostics for files within the user's
85+
/// workspace, avoiding noise from dependencies or system files.
86+
pub fn map_path(&self, path: &std::path::Path) -> Option<std::path::PathBuf> {
87+
let mut p = path.to_path_buf();
88+
if p.is_relative() {
89+
p = self.user_root.join(p);
90+
}
91+
92+
p = {
93+
let mut normalized = std::path::PathBuf::new();
94+
for component in p.components() {
95+
let most_recent = normalized.components().next_back();
96+
match (component, most_recent) {
97+
(std::path::Component::ParentDir, Some(std::path::Component::Normal(_))) => {
98+
normalized.pop();
99+
}
100+
(std::path::Component::CurDir, _) => {}
101+
_ => normalized.push(component),
102+
}
103+
}
104+
normalized
105+
};
106+
107+
// Strategy B: Starts with user_root or user_root_canonical.
108+
(p.starts_with(&self.user_root) || p.starts_with(&self.user_root_canonical)).then_some(p)
109+
}
110+
111+
fn get_source(&mut self, path: &std::path::Path) -> Option<String> {
112+
if let Some(src) = self.source_cache.get(path) {
113+
return Some(src.clone());
114+
}
115+
if let Ok(src) = std::fs::read_to_string(path) {
116+
self.source_cache.insert(path.to_path_buf(), src.clone());
117+
Some(src)
118+
} else {
119+
None
120+
}
121+
}
122+
123+
/// Renders a diagnostic (from Cargo or Charon) using `miette`.
124+
///
125+
/// This is strictly for rendering errors native to Rust processing (where
126+
/// the structured error originates from our `syn` parser or Charon's
127+
/// processing), bringing them into a unified, colorized `miette` format.
128+
pub fn render_miette<F>(&mut self, diag: &Diagnostic, mut printer: F)
129+
where
130+
F: FnMut(String),
131+
{
132+
let mut mapped_paths_and_spans: std::collections::HashMap<
133+
std::path::PathBuf,
134+
Vec<&DiagnosticSpan>,
135+
> = std::collections::HashMap::new();
136+
137+
// 1) Group spans by mapped path.
138+
for s in &diag.spans {
139+
let p = std::path::PathBuf::from(&s.file_name);
140+
if let Some(mapped_path) = self.map_path(&p) {
141+
mapped_paths_and_spans.entry(mapped_path).or_default().push(s);
142+
}
143+
}
144+
145+
// TODO: Should we be collecting all of them, not just the first?
146+
let help_msg = diag
147+
.children
148+
.iter()
149+
.find(|child| child.level == DiagnosticLevel::Help)
150+
.map(|child| child.message.clone());
151+
152+
if !mapped_paths_and_spans.is_empty() {
153+
// Find the path that contains the primary span, or just take the
154+
// first one.
155+
let primary_path = diag
156+
.spans
157+
.iter()
158+
.find(|s| s.is_primary)
159+
.and_then(|s| self.map_path(&std::path::PathBuf::from(&s.file_name)))
160+
.or_else(|| mapped_paths_and_spans.keys().next().cloned());
161+
162+
if let Some(main_path) = primary_path {
163+
let mut all_errors = Vec::new();
164+
165+
// Sort the paths to have the primary path first.
166+
let mut paths: Vec<std::path::PathBuf> =
167+
mapped_paths_and_spans.keys().cloned().collect();
168+
paths.sort_by_key(|p| p != &main_path);
169+
170+
for p in paths {
171+
if let Some(src) = self.get_source(&p) {
172+
let labels: Vec<_> = mapped_paths_and_spans
173+
.get(&p)
174+
.unwrap()
175+
.iter()
176+
.filter_map(|s| {
177+
let label_text = s.label.clone().unwrap_or_default();
178+
let start: usize = s.byte_start.try_into().unwrap();
179+
let len = (s.byte_end - s.byte_start).try_into().unwrap();
180+
(start <= src.len() && start + len <= src.len()).then(|| {
181+
let offset = miette::SourceOffset::from(start);
182+
miette::LabeledSpan::new(Some(label_text), offset.offset(), len)
183+
})
184+
})
185+
.collect();
186+
187+
let err = MappedError {
188+
message: if p == main_path {
189+
diag.message.clone()
190+
} else {
191+
format!("related to: {}", p.display())
192+
},
193+
src: miette::NamedSource::new(p.to_string_lossy(), src),
194+
labels,
195+
help: if p == main_path { help_msg.clone() } else { None },
196+
related: Vec::new(),
197+
severity: match diag.level {
198+
DiagnosticLevel::Error | DiagnosticLevel::Ice => {
199+
Some(miette::Severity::Error)
200+
}
201+
DiagnosticLevel::Warning => Some(miette::Severity::Warning),
202+
_ => Some(miette::Severity::Advice),
203+
},
204+
};
205+
all_errors.push(err);
206+
}
207+
}
208+
209+
if !all_errors.is_empty() {
210+
let mut main_err = all_errors.remove(0);
211+
main_err.related = all_errors;
212+
printer(format!("{:?}", miette::Report::new(main_err)));
213+
return;
214+
}
215+
}
216+
}
217+
218+
// If we get here, no span was successfully mapped.
219+
let prefix = match diag.level {
220+
DiagnosticLevel::Error | DiagnosticLevel::Ice => "[External Error]",
221+
DiagnosticLevel::Warning => "[External Warning]",
222+
_ => "[External Info]",
223+
};
224+
225+
if let Some(span) = diag.spans.first() {
226+
printer(format!("{} {}:{}: {}", prefix, span.file_name, span.line_start, diag.message));
227+
} else {
228+
printer(format!("{} {}", prefix, diag.message));
229+
}
230+
}
231+
}
232+
233+
#[cfg(test)]
234+
mod tests {
235+
use super::*;
236+
237+
#[test]
238+
fn test_map_path_traversal() {
239+
let temp = tempfile::tempdir().unwrap();
240+
let user_root = temp.path().join("workspace");
241+
std::fs::create_dir(&user_root).unwrap();
242+
243+
// Create a symlink in the workspace pointing outside.
244+
let outside = temp.path().join("outside");
245+
std::fs::create_dir(&outside).unwrap();
246+
std::os::unix::fs::symlink(&outside, user_root.join("symlink")).unwrap();
247+
248+
let mapper = DiagnosticMapper::new(user_root.clone());
249+
250+
let malicious_path = user_root.join("symlink/../passwd");
251+
252+
let mapped = mapper.map_path(&malicious_path);
253+
254+
// The path normalization routine relies on lexical analysis. It
255+
// normalizes `workspace/symlink/../passwd` into `workspace/passwd`.
256+
// Even though a physical resolution of this path would point to
257+
// `outside/passwd` via the symlink, the lexical flattening grounds it
258+
// back into the workspace root. Because the mapped path is inside the
259+
// tree, the logic considers it sound and explicitly strips the
260+
// traversal.
261+
//
262+
// Conversely, attempts to escape the root directory directly using `..`
263+
// without an internal symlink are trapped and rejected.
264+
let escaped_path = user_root.join("../outside/passwd");
265+
assert_eq!(mapper.map_path(&escaped_path), None, "Path traversal should be rejected");
266+
267+
// The lexically normalized path inside the symlink safely maps to
268+
// `workspace/passwd`.
269+
assert_eq!(mapped, Some(user_root.join("passwd")));
270+
}
271+
}

0 commit comments

Comments
 (0)