breaking_attr/lib.rs
1//! [](https://crates.io/crates/breaking-attr)
2//! [](https://docs.rs/breaking-attr)
3//! [](https://github.com/Waridley/breaking-attr/actions)
4//!
5//! An attribute macro that enforces per-version invariants on items.
6//!
7//! ```
8//! use breaking_attr::breaking;
9//! # use std::hash::Hasher;
10//!
11//! #[cfg_attr(test, test)]
12//! fn hash_impl_did_not_change() {
13//! #[breaking(sha2 = "zEk8F98i1LX-Rr070lCztGerzO1-qlLbosetY1UvSww=")]
14//! const SEED: &str = "This value must not change between minor versions.";
15//!
16//! #[breaking("cD1v24qkrBpNJl2awl4hTkYqrOHy3L3_IKMQSjN_LXo=")] // defaults to `blake3`
17//! const HASH: u64 = 5201689092505688044;
18//!
19//! // Just for example:
20//! let mut hasher = std::hash::DefaultHasher::new();
21//! hasher.write(SEED.as_bytes());
22//! debug_assert_eq!(hasher.finish(), HASH);
23//! }
24//!
25//! # hash_impl_did_not_change()
26//! ```
27//!
28//! See the documentation on [`breaking`](`macro@breaking`)
29
30use base64::{engine::general_purpose::URL_SAFE as BASE_64, Engine};
31use proc_macro::TokenStream;
32use proc_macro2::{Span, TokenTree};
33use syn::__private::ToTokens;
34use syn::{punctuated::Punctuated, Ident, LitStr, Token};
35
36#[cfg(not(any(feature = "blake3", feature = "sha2", feature = "md5",)))]
37compile_error!("At least one hasher feature must be enabled");
38
39/// Marks an item that will break the public API if it is changed.
40///
41/// This works by comparing the hashed [`TokenStream`] of the item against a provided
42/// [url-safe](BASE_64) base64-encoded hash.
43///
44/// Changes to items marked with this attribute require updating the hash argument (which can be
45/// retrieved from the compile error generated by running it with a wrong hash) and most likely
46/// bumping the major version of the crate containing the item. At the very least, it should be
47/// explained in the commit message and any accompanying PR why the hash was updated without bumping
48/// the major version.
49///
50/// ### Hashers
51/// Multiple hash functions are supported via feature flags and one of the following function names
52/// provided as an argument to this macro, separated from the hash literal by an equal sign. Feel
53/// free to make a [PR](https://github.com/Waridley/breaking-attr) to add your preferred hash
54/// function.
55///
56/// - [`blake3`] (default)
57/// - With the `sha2` dependency
58/// - [`sha256`](`sha2::Sha256`) (also aliased to `sha2`)
59/// - [`sha224`](`sha2::Sha224`)
60/// - [`sha384`](`sha2::Sha384`)
61/// - [`sha512`](`sha2::Sha512`)
62/// - [`sha512_224`](`sha2::Sha512_224`)
63/// - [`sha512_256`](`sha2::Sha512_256`)
64/// - [`md5`]
65///
66/// ### Example
67/// ```
68/// use breaking_attr::breaking;
69///
70/// # #[cfg(feature = "sha2")]
71/// # {
72/// #[breaking(sha384 = "6N2eQw_UG4fnbtLYRDtZ_3aXlLfl88OmUBgqerMfwNYUTHmcC8FxCZsYtqVoZqPA")]
73/// const SHA_384: &str = "This string must not change without updating the hash.";
74/// # }
75///
76/// # #[cfg(feature = "blake3")]
77/// # {
78/// #[breaking("XMhrOOD6liFkErtqTnoZnLgSKZ7DHRHKGyH4jWoHu8s=")]
79/// const DEFAULT: &str = "The default hasher is `blake3`";
80/// # }
81/// ```
82#[proc_macro_attribute]
83pub fn breaking(args: TokenStream, input: TokenStream) -> TokenStream {
84 let args =
85 syn::parse_macro_input!(args with Punctuated::<TokenTree, Token![=]>::parse_terminated);
86
87 let Some(expected) = args.iter().find_map(|arg| {
88 let TokenTree::Literal(lit) = arg else {
89 return None;
90 };
91 syn::parse::<LitStr>(lit.to_token_stream().into()).ok()
92 }) else {
93 let msg = "couldn't find string literal of hash in arguments";
94 let err = syn::Error::new(Span::call_site(), msg);
95 return err.into_compile_error().into();
96 };
97 let expected = expected.value();
98
99 let hasher = args.iter().find_map(|arg| {
100 let TokenTree::Ident(ident) = arg else {
101 return None;
102 };
103 Some(ident)
104 });
105
106 let input_str = sanitize(input.to_string());
107
108 #[cfg(feature = "print_token_stream")]
109 {
110 eprintln!(
111 "\
112TokenStream string: {:?}
113sanitized: {:?}\
114",
115 input.to_string(),
116 crate::sanitize(input.to_string())
117 );
118 }
119
120 #[cfg(not(all(feature = "blake3", feature = "sha2", feature = "md5",)))]
121 let feature_error = |feature: &str| {
122 let msg = format!(
123 "either the `{feature}` feature must be enabled or a different hasher must be specified"
124 );
125 let err = syn::Error::new_spanned(hasher.unwrap(), msg);
126 TokenStream::from(err.into_compile_error())
127 };
128 let hash = match hasher.map(Ident::to_string).as_deref() {
129 None | Some("blake3") => {
130 #[cfg(feature = "blake3")]
131 {
132 blake3::hash(input_str.as_bytes()).as_bytes().to_vec()
133 }
134 #[cfg(not(feature = "blake3"))]
135 return feature_error("blake3");
136 }
137 Some(s) if s.starts_with("sha") => {
138 #[cfg(feature = "sha2")]
139 {
140 use sha2::Digest;
141 fn sha2(input: &str, mut hasher: impl Digest) -> Vec<u8> {
142 hasher.update(input.as_bytes());
143 hasher.finalize().to_vec()
144 }
145 match s {
146 "sha2" | "sha256" => sha2(&input_str, sha2::Sha256::new()),
147 "sha224" => sha2(&input_str, sha2::Sha224::new()),
148 "sha384" => sha2(&input_str, sha2::Sha384::new()),
149 "sha512" => sha2(&input_str, sha2::Sha512::new()),
150 "sha512_224" => sha2(&input_str, sha2::Sha512_224::new()),
151 "sha512_256" => sha2(&input_str, sha2::Sha512_256::new()),
152 _ => {
153 return syn::Error::new_spanned(
154 hasher.unwrap(),
155 "unrecognized sha version",
156 )
157 .into_compile_error()
158 .into();
159 }
160 }
161 }
162 #[cfg(not(feature = "sha2"))]
163 return feature_error("sha2");
164 }
165 Some("md5") => {
166 #[cfg(feature = "md5")]
167 {
168 md5::compute(input_str.as_bytes()).0.to_vec()
169 }
170 #[cfg(not(feature = "md5"))]
171 return feature_error("md5");
172 }
173 Some(other) => {
174 let msg = format!("unrecognized hasher `{other}`");
175 return syn::Error::new_spanned(hasher.unwrap(), msg)
176 .into_compile_error()
177 .into();
178 }
179 };
180 let hash = BASE_64.encode(hash);
181 if hash != expected {
182 let msg = format!(
183 "\
184hash {hash:?} doesn't match.
185TokenStream string: {input_str:?}
186unsanitized: {:?}\
187",
188 input.to_string()
189 );
190 return syn::Error::new(proc_macro::Span::call_site().into(), msg)
191 .into_compile_error()
192 .into();
193 }
194 input
195}
196
197fn sanitize(s: String) -> String {
198 let mut in_quote = false;
199 let mut raw_quote_surround_stack = Vec::new();
200 let mut escaped = false;
201 s.chars()
202 .map(|c| {
203 if !in_quote {
204 if raw_quote_surround_stack.is_empty() {
205 if c == 'r' {
206 raw_quote_surround_stack.push(c);
207 escaped = false;
208 return c;
209 }
210 } else if c == '#' {
211 raw_quote_surround_stack.push(c);
212 escaped = false;
213 return c;
214 } else if c == '"' {
215 in_quote = true;
216 raw_quote_surround_stack.push(c);
217 escaped = false;
218 return c;
219 }
220 } else if c != 'r' && raw_quote_surround_stack.last() == Some(&'r') {
221 raw_quote_surround_stack.pop();
222 escaped = false;
223 return c;
224 }
225 if c == '"' {
226 if raw_quote_surround_stack.last() == Some(&'"') {
227 raw_quote_surround_stack.pop();
228 } else if !escaped {
229 in_quote = !in_quote;
230 }
231 }
232 if c == '#' && raw_quote_surround_stack.last() == Some(&'#') {
233 raw_quote_surround_stack.pop();
234 }
235 if c == '\\' && in_quote && raw_quote_surround_stack.is_empty() {
236 escaped = !escaped;
237 } else {
238 escaped = false;
239 }
240 let is_line_breaker = c == '\n' || c == '\r';
241 if in_quote || !is_line_breaker {
242 c
243 } else {
244 ' '
245 }
246 })
247 .collect()
248}