fscrypt/
proxy_filename.rs

1// Copyright 2025 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4use anyhow::{Error, anyhow, ensure};
5use base64::Engine as _;
6use base64::engine::general_purpose::URL_SAFE_NO_PAD;
7use sha2::Digest;
8use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout, Unaligned};
9
10use crate::direntry;
11
12const MAX_FILENAME_LEN: usize = 149;
13
14/// A proxy filename is used when we don't have the keys to decrypt the actual filename.
15///
16/// When working with locked directories, we encode both Dentry and user provided
17/// filenames using this struct before comparing them.
18///
19/// The hash code is encoded directly, allowing an index in some cases, even when entries
20/// are encrypted.
21///
22/// As a work around to keep uniqueness, for filenames longer than 149 bytes, we use the filename
23/// prefix and calculate the sha256 of the full encrypted name.  This produces a filename that is
24/// under the limit and very likely to remain unique.
25///
26/// The filename prefix size is chosen here for compatibility.
27#[repr(C, packed)]
28#[derive(
29    Copy, Clone, Debug, Eq, PartialEq, FromBytes, Immutable, KnownLayout, IntoBytes, Unaligned,
30)]
31pub struct ProxyFilename {
32    pub hash_code: u64,
33    pub filename: [u8; MAX_FILENAME_LEN],
34    pub sha256: [u8; 32],
35    // 'len' holds the length in bytes of the material that gets base64 encoded.
36    //
37    // The fields above are treated as an array of bytes and directly base64 encoded to give
38    // a proxy filename. The length of this encoding varies. It is always at least 8 bytes
39    // (covering hash_code) and covers the filename if 149 bytes or shorter, but if the filename
40    // exceeds 149 characters then the length will always be the full size of hash_code, filename
41    // prefix and sha256.
42    // This length is not stored anywhere and cannot be implied from the encrypted filename as
43    // it may contain NULL characters so we tack it onto the end here. It is not serialized.
44    len: usize,
45}
46
47/// The maximum length of ProxyFilename before being base64 encoded.
48const PROXY_FILENAME_MAX_SIZE: usize = 8 + 149 + 32;
49
50impl Default for ProxyFilename {
51    fn default() -> Self {
52        Self { hash_code: 0, filename: [0u8; 149], sha256: [0u8; 32], len: 0 }
53    }
54}
55
56impl ProxyFilename {
57    /// Create a new ProxyFilename, deriving hash_code in a Linux-compatible way. This should
58    /// only be used for non-case-folded filenames, as the hash code for case-folded filenames
59    /// cannot be derived from the raw filename alone.
60    pub fn new(raw_filename: &[u8]) -> Self {
61        Self::new_with_hash_code(Self::compute_hash_code(raw_filename), raw_filename)
62    }
63
64    /// When referring to an encrypted + casefolded file, we must supply the hash_code
65    /// directly as it isn't trivially derivable from filename.
66    pub fn new_with_hash_code(hash_code: u64, raw_filename: &[u8]) -> Self {
67        let mut filename = [0u8; 149];
68        let mut sha256 = [0u8; 32];
69        let len = if raw_filename.len() <= filename.len() {
70            filename[..raw_filename.len()].copy_from_slice(raw_filename);
71            std::mem::size_of::<u64>() + raw_filename.len()
72        } else {
73            let len = filename.len();
74            filename.copy_from_slice(&raw_filename[..len]);
75            sha256 = Self::compute_sha256(raw_filename).into();
76            PROXY_FILENAME_MAX_SIZE
77        };
78        Self { hash_code, filename, sha256, len }
79    }
80
81    /// Returns the raw filename (which might be truncated).
82    pub fn raw_filename(&self) -> &[u8] {
83        if self.len == PROXY_FILENAME_MAX_SIZE {
84            &self.filename
85        } else {
86            &self.filename[..self.len - std::mem::size_of::<u64>()]
87        }
88    }
89
90    /// Computes a non-casefolded hash code.
91    pub fn compute_hash_code(raw_filename: &[u8]) -> u64 {
92        direntry::tea_hash_filename(raw_filename) as u64
93    }
94
95    /// Computes the sha256 part of the proxy name.
96    ///
97    /// # Panics
98    ///
99    /// This will panic if the raw filename is too short and therefore does not require a sha256
100    /// hash.
101    pub fn compute_sha256(raw_filename: &[u8]) -> [u8; 32] {
102        sha2::Sha256::digest(&raw_filename[MAX_FILENAME_LEN..]).into()
103    }
104
105    /// Returns true if the proxy filename has been truncated (and therefore includes a sha256
106    /// hash).
107    pub fn is_truncated(&self) -> bool {
108        self.len == PROXY_FILENAME_MAX_SIZE
109    }
110}
111
112impl Into<String> for ProxyFilename {
113    fn into(self) -> String {
114        URL_SAFE_NO_PAD.encode(&self.as_bytes()[..self.len])
115    }
116}
117
118impl TryFrom<&str> for ProxyFilename {
119    type Error = Error;
120    fn try_from(s: &str) -> Result<Self, Self::Error> {
121        let mut bytes = URL_SAFE_NO_PAD.decode(s).map_err(|_| anyhow!("Invalid proxy filename"))?;
122        ensure!(
123            (bytes.len() >= 8 && bytes.len() <= 8 + 149) || bytes.len() == PROXY_FILENAME_MAX_SIZE,
124            "Invalid proxy filename length {}",
125            bytes.len()
126        );
127        let len = bytes.len();
128        bytes.resize(std::mem::size_of::<ProxyFilename>(), 0);
129        let mut instance = Self::read_from_bytes(&bytes).unwrap();
130        instance.len = len;
131        Ok(instance)
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    /// The maximum length of ProxyFilename encoded with base64.
140    const PROXY_FILENAME_MAX_ENCODED_SIZE: usize = (PROXY_FILENAME_MAX_SIZE * 4).div_ceil(3);
141
142    #[test]
143    fn test_proxy_filename() {
144        // Invalid filename works when encoded.
145        let a = ProxyFilename::new(b"fo!$obar");
146        let encoded: String = a.into();
147        let b: ProxyFilename = encoded.as_str().try_into().unwrap();
148        assert_eq!(a, b);
149
150        // Short filename.
151        let a = ProxyFilename::new(b"foobar");
152        let encoded: String = a.into();
153        let b: ProxyFilename = encoded.as_str().try_into().unwrap();
154        assert_eq!(a, b);
155
156        // 149 length filename.
157        let a = ProxyFilename::new(&[0xff; 149]);
158        let encoded: String = a.into();
159        let b: ProxyFilename = encoded.as_str().try_into().unwrap();
160        assert_eq!(a, b);
161        // Account for base64 bloat (Length growth by 4/3rd, rounded up)
162        assert_eq!(encoded.len(), ((8 + 149) * 4usize).div_ceil(3));
163
164        // 150 length filename -- now has sha256 suffix.
165        // Note the filename is all zeros. This should not affect the output.
166        let a = ProxyFilename::new(&[0; 150]);
167        let encoded: String = a.into();
168        let b: ProxyFilename = encoded.as_str().try_into().unwrap();
169        assert_eq!(a, b);
170        assert_eq!(encoded.len(), PROXY_FILENAME_MAX_ENCODED_SIZE);
171
172        // 255 length filename
173        let a = ProxyFilename::new(&[b'a'; 255]);
174        let encoded: String = a.into();
175        let b: ProxyFilename = encoded.as_str().try_into().unwrap();
176        assert_eq!(a, b);
177        assert_eq!(encoded.len(), PROXY_FILENAME_MAX_ENCODED_SIZE);
178
179        // 255 length filename with explicit hash_code
180        let a = ProxyFilename::new_with_hash_code(1, &[b'a'; 255]);
181        let encoded: String = a.into();
182        let b: ProxyFilename = encoded.as_str().try_into().unwrap();
183        assert_eq!(a, b);
184        assert_eq!(encoded.len(), PROXY_FILENAME_MAX_ENCODED_SIZE);
185
186        // Decoding of bad base64 strings.
187        assert!(ProxyFilename::try_from("$$dda123=").is_err());
188
189        assert_eq!(
190            URL_SAFE_NO_PAD.encode(&[0; PROXY_FILENAME_MAX_SIZE]).len(),
191            PROXY_FILENAME_MAX_ENCODED_SIZE
192        );
193
194        // Decoding of bad lengths.
195        // Valid lengths include the 8 byte hash_code and a filename up to 149 characters.
196        // If the filename exceeds that, the length should be exactly PROXY_FILENAME_MAX_SIZE.
197        assert!(ProxyFilename::try_from(URL_SAFE_NO_PAD.encode(&[b'a'; 0]).as_str()).is_err());
198        assert!(ProxyFilename::try_from(URL_SAFE_NO_PAD.encode(&[b'a'; 7]).as_str()).is_err());
199        assert!(ProxyFilename::try_from(URL_SAFE_NO_PAD.encode(&[b'a'; 8]).as_str()).is_ok());
200        assert!(ProxyFilename::try_from(URL_SAFE_NO_PAD.encode(&[b'a'; 9]).as_str()).is_ok());
201        assert!(ProxyFilename::try_from(URL_SAFE_NO_PAD.encode(&[b'a'; 8 + 148]).as_str()).is_ok());
202        assert!(ProxyFilename::try_from(URL_SAFE_NO_PAD.encode(&[b'a'; 8 + 149]).as_str()).is_ok());
203        assert!(
204            ProxyFilename::try_from(URL_SAFE_NO_PAD.encode(&[b'a'; 8 + 150]).as_str()).is_err()
205        );
206        assert!(
207            ProxyFilename::try_from(
208                URL_SAFE_NO_PAD.encode(&[b'a'; PROXY_FILENAME_MAX_SIZE - 1]).as_str()
209            )
210            .is_err()
211        );
212        assert!(
213            ProxyFilename::try_from(
214                URL_SAFE_NO_PAD.encode(&[b'a'; PROXY_FILENAME_MAX_SIZE]).as_str()
215            )
216            .is_ok()
217        );
218        assert!(
219            ProxyFilename::try_from(
220                URL_SAFE_NO_PAD.encode(&[b'a'; PROXY_FILENAME_MAX_SIZE + 1]).as_str()
221            )
222            .is_err()
223        );
224    }
225}