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