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}