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//!
10//! # use blake3::{hash, Hash};
11//!
12//! #[cfg_attr(test, test)]
13//! fn hash_impl_did_not_change() {
14//! #[breaking(sha2 = "zEk8F98i1LX-Rr070lCztGerzO1-qlLbosetY1UvSww=")]
15//! const SEED: &str = "This value must not change between minor versions.";
16//! #[breaking("ipCxMeWK-ImjLQVZX7YQXqjguSC5jOc5oCexXl5Bw0g=")] // Hasher defaults to `blake3`
17//! const HASH: &[u8] = &[89, 216, 231, 42, 61, 80, 228, 91, 200, 17, 218, 190, 61, 187, 1, 13, 90, 195, 84, 57, 249, 109, 90, 79, 175, 103, 37, 195, 98, 32, 157, 102];
18//!
19//! let hash = hash(SEED.as_bytes());
20//! debug_assert_eq!(hash.as_bytes(), HASH);
21//! }
22//!
23//! # hash_impl_did_not_change()
24//! ```
25//!
26//! See the documentation on [`breaking`](`macro@breaking`)
27
28use base64::{Engine, engine::general_purpose::URL_SAFE as BASE_64};
29use proc_macro::TokenStream;
30use proc_macro2::{Span, TokenTree};
31use syn::__private::ToTokens;
32use syn::{Ident, LitStr, Token, punctuated::Punctuated};
33
34#[cfg(not(any(feature = "blake3", feature = "sha2", feature = "md5",)))]
35compile_error!("At least one hasher feature must be enabled");
36
37/// Marks an item that will break the public API if it is changed.
38///
39/// This attribute takes a single argument, which must be a string literal of the url-safe base64-
40/// encoded hash of the token stream of the item marked by this attribute.
41///
42/// Changes to items marked with this attribute require updating the hash argument (which can be
43/// retrieved from the compile error generated by running it with a wrong hash) and most likely
44/// bumping the major version of the crate containing the item. At the very least, it must be
45/// explained in the commit message and any accompanying PR why the hash was updated without bumping
46/// the major version.
47///
48/// ### Hashers
49/// Multiple hash functions are supported via feature flags.
50///
51/// - [`blake3`] (default)
52/// - With the `sha2` dependency
53/// - [`sha256`](`sha2::Sha256`) (also aliased to `sha2`)
54/// - [`sha224`](`sha2::Sha224`)
55/// - [`sha384`](`sha2::Sha384`)
56/// - [`sha512`](`sha2::Sha512`)
57/// - [`sha512_224`](`sha2::Sha512_224`)
58/// - [`sha512_256`](`sha2::Sha512_256`)
59/// - [`md5`]
60///
61/// ```
62/// use breaking_attr::breaking;
63///
64/// # #[cfg(feature = "sha2")]
65/// #[breaking(sha384 = "82y9Notlejn-Nfzl4SurR3m3Uqeaqt0jmN-wGHSAjNkHeywz1zYZeUJi-5-D0wo3")]
66/// const SHA_384: &str = "This string must not change without updating the hash.";
67///
68/// # #[cfg(feature = "blake3")]
69/// #[breaking("IjrbZ-YsIRSb2v3ELtz-4zMqGvu5FVCkwotqCKMdhDE=")]
70/// const DEFAULT: &str = "The default hasher is `blake3`";
71/// ```
72#[proc_macro_attribute]
73pub fn breaking(args: TokenStream, input: TokenStream) -> TokenStream {
74 let args =
75 syn::parse_macro_input!(args with Punctuated::<TokenTree, Token![=]>::parse_terminated);
76
77 let Some(expected) = args.iter().find_map(|arg| {
78 let TokenTree::Literal(lit) = arg else {
79 return None;
80 };
81 syn::parse::<LitStr>(lit.to_token_stream().into()).ok()
82 }) else {
83 let msg = "couldn't find string literal of hash in arguments";
84 let err = syn::Error::new(Span::call_site(), msg);
85 return err.into_compile_error().into();
86 };
87 let expected = expected.value();
88
89 let hasher = args.iter().find_map(|arg| {
90 let TokenTree::Ident(ident) = arg else {
91 return None;
92 };
93 Some(ident)
94 });
95 let input_str = input.to_string();
96 #[cfg(not(all(feature = "blake3", feature = "sha2", feature = "md5",)))]
97 let feature_error = |feature: &str| {
98 let msg = format!(
99 "either the `{feature}` feature must be enabled or a different hasher must be specified"
100 );
101 let err = syn::Error::new_spanned(hasher.unwrap(), msg);
102 TokenStream::from(err.into_compile_error())
103 };
104 let hash = match hasher.map(Ident::to_string).as_deref() {
105 None | Some("blake3") => {
106 #[cfg(feature = "blake3")]
107 {
108 blake3::hash(input_str.as_bytes()).as_bytes().to_vec()
109 }
110 #[cfg(not(feature = "blake3"))]
111 return feature_error("blake3");
112 }
113 Some(s) if s.starts_with("sha") => {
114 #[cfg(feature = "sha2")]
115 {
116 use sha2::Digest;
117 fn sha2(input: &str, mut hasher: impl Digest) -> Vec<u8> {
118 hasher.update(input.as_bytes());
119 hasher.finalize().to_vec()
120 }
121 match s {
122 "sha2" | "sha256" => sha2(&input_str, sha2::Sha256::new()),
123 "sha224" => sha2(&input_str, sha2::Sha224::new()),
124 "sha384" => sha2(&input_str, sha2::Sha384::new()),
125 "sha512" => sha2(&input_str, sha2::Sha512::new()),
126 "sha512_224" => sha2(&input_str, sha2::Sha512_224::new()),
127 "sha512_256" => sha2(&input_str, sha2::Sha512_256::new()),
128 _ => {
129 return syn::Error::new_spanned(
130 hasher.unwrap(),
131 "unrecognized sha version",
132 )
133 .into_compile_error()
134 .into();
135 }
136 }
137 }
138 #[cfg(not(feature = "sha2"))]
139 return feature_error("sha2");
140 }
141 Some("md5") => {
142 #[cfg(feature = "md5")]
143 {
144 md5::compute(input_str.as_bytes()).0.to_vec()
145 }
146 #[cfg(not(feature = "md5"))]
147 return feature_error("md5");
148 }
149 Some(other) => {
150 let msg = format!("unrecognized hasher `{other}`");
151 return syn::Error::new_spanned(hasher.unwrap(), msg)
152 .into_compile_error()
153 .into();
154 }
155 };
156 let hash = BASE_64.encode(hash);
157 if hash != expected {
158 let msg = format!("hash {hash:?} doesn't match");
159 return syn::Error::new(proc_macro::Span::call_site().into(), msg)
160 .into_compile_error()
161 .into();
162 }
163 input
164}