breaking_attr/
lib.rs

1//! [![Crates.io Version](https://img.shields.io/crates/v/breaking-attr)](https://crates.io/crates/breaking-attr)
2//! [![docs.rs](https://img.shields.io/docsrs/breaking-attr)](https://docs.rs/breaking-attr)
3//! [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/Waridley/breaking-attr/.github%2Fworkflows%2Ftests.yml)](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}