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/// #[breaking(sha384 = "82y9Notlejn-Nfzl4SurR3m3Uqeaqt0jmN-wGHSAjNkHeywz1zYZeUJi-5-D0wo3")]
72/// const SHA_384: &str = "This string must not change without updating the hash.";
73///
74/// # #[cfg(feature = "blake3")]
75/// #[breaking("IjrbZ-YsIRSb2v3ELtz-4zMqGvu5FVCkwotqCKMdhDE=")]
76/// const DEFAULT: &str = "The default hasher is `blake3`";
77/// ```
78#[proc_macro_attribute]
79pub fn breaking(args: TokenStream, input: TokenStream) -> TokenStream {
80 let args =
81 syn::parse_macro_input!(args with Punctuated::<TokenTree, Token![=]>::parse_terminated);
82
83 let Some(expected) = args.iter().find_map(|arg| {
84 let TokenTree::Literal(lit) = arg else {
85 return None;
86 };
87 syn::parse::<LitStr>(lit.to_token_stream().into()).ok()
88 }) else {
89 let msg = "couldn't find string literal of hash in arguments";
90 let err = syn::Error::new(Span::call_site(), msg);
91 return err.into_compile_error().into();
92 };
93 let expected = expected.value();
94
95 let hasher = args.iter().find_map(|arg| {
96 let TokenTree::Ident(ident) = arg else {
97 return None;
98 };
99 Some(ident)
100 });
101 let input_str = input.to_string();
102 #[cfg(not(all(feature = "blake3", feature = "sha2", feature = "md5",)))]
103 let feature_error = |feature: &str| {
104 let msg = format!(
105 "either the `{feature}` feature must be enabled or a different hasher must be specified"
106 );
107 let err = syn::Error::new_spanned(hasher.unwrap(), msg);
108 TokenStream::from(err.into_compile_error())
109 };
110 let hash = match hasher.map(Ident::to_string).as_deref() {
111 None | Some("blake3") => {
112 #[cfg(feature = "blake3")]
113 {
114 blake3::hash(input_str.as_bytes()).as_bytes().to_vec()
115 }
116 #[cfg(not(feature = "blake3"))]
117 return feature_error("blake3");
118 }
119 Some(s) if s.starts_with("sha") => {
120 #[cfg(feature = "sha2")]
121 {
122 use sha2::Digest;
123 fn sha2(input: &str, mut hasher: impl Digest) -> Vec<u8> {
124 hasher.update(input.as_bytes());
125 hasher.finalize().to_vec()
126 }
127 match s {
128 "sha2" | "sha256" => sha2(&input_str, sha2::Sha256::new()),
129 "sha224" => sha2(&input_str, sha2::Sha224::new()),
130 "sha384" => sha2(&input_str, sha2::Sha384::new()),
131 "sha512" => sha2(&input_str, sha2::Sha512::new()),
132 "sha512_224" => sha2(&input_str, sha2::Sha512_224::new()),
133 "sha512_256" => sha2(&input_str, sha2::Sha512_256::new()),
134 _ => {
135 return syn::Error::new_spanned(
136 hasher.unwrap(),
137 "unrecognized sha version",
138 )
139 .into_compile_error()
140 .into();
141 }
142 }
143 }
144 #[cfg(not(feature = "sha2"))]
145 return feature_error("sha2");
146 }
147 Some("md5") => {
148 #[cfg(feature = "md5")]
149 {
150 md5::compute(input_str.as_bytes()).0.to_vec()
151 }
152 #[cfg(not(feature = "md5"))]
153 return feature_error("md5");
154 }
155 Some(other) => {
156 let msg = format!("unrecognized hasher `{other}`");
157 return syn::Error::new_spanned(hasher.unwrap(), msg)
158 .into_compile_error()
159 .into();
160 }
161 };
162 let hash = BASE_64.encode(hash);
163 if hash != expected {
164 let msg = format!("hash {hash:?} doesn't match");
165 return syn::Error::new(proc_macro::Span::call_site().into(), msg)
166 .into_compile_error()
167 .into();
168 }
169 input
170}