Skip to main content

a7n_core/
files.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3//! File manifest validation, target names and resume offsets without I/O.
4
5use std::collections::HashMap;
6
7use a7n_proto::limits;
8use a7n_proto::v1::{FileKind, FileManifest, FileResume};
9
10mod path;
11pub use path::{ManifestPath, PathError};
12
13const NAME_ATTEMPTS_MAX: usize = 1000;
14
15#[derive(Debug, thiserror::Error, PartialEq, Eq)]
16pub enum ManifestError {
17    #[error("manifest exceeds the entry limit")]
18    TooManyEntries,
19    #[error("entry {index} has an invalid path: {source}")]
20    Path { index: usize, source: PathError },
21    #[error("entry {index} duplicates a path")]
22    DuplicatePath { index: usize },
23    #[error("entry {index} precedes a parent directory")]
24    ParentOrder { index: usize },
25    #[error("entry {index} has a file as a parent")]
26    FileParent { index: usize },
27    #[error("entry {index} has an unspecified or unknown kind")]
28    Kind { index: usize },
29    #[error("entry {index} has an invalid SHA-256 length")]
30    Digest { index: usize },
31    #[error("directory entry {index} has nonzero size")]
32    DirectorySize { index: usize },
33    #[error("directory entry {index} has a digest")]
34    DirectoryDigest { index: usize },
35    #[error("file size sum overflows at entry {index}")]
36    SizeOverflow { index: usize },
37    #[error("file size sum does not match total size")]
38    TotalSize,
39}
40
41/// File metadata that cannot represent an unspecified kind or malformed digest.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum EntryKind {
44    File { size: u64, sha256: [u8; 32] },
45    Directory,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Entry {
50    pub path: ManifestPath,
51    pub kind: EntryKind,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct ValidManifest {
56    entries: Vec<Entry>,
57}
58
59impl ValidManifest {
60    #[must_use]
61    pub fn entries(&self) -> &[Entry] {
62        &self.entries
63    }
64}
65
66impl TryFrom<&FileManifest> for ValidManifest {
67    type Error = ManifestError;
68
69    fn try_from(manifest: &FileManifest) -> Result<Self, Self::Error> {
70        if manifest.entries.len() > limits::MANIFEST_ENTRIES_MAX {
71            return Err(ManifestError::TooManyEntries);
72        }
73        let mut entries = Vec::with_capacity(manifest.entries.len());
74        let mut paths = HashMap::with_capacity(manifest.entries.len());
75        let mut total = 0_u64;
76        for (index, entry) in manifest.entries.iter().enumerate() {
77            let path = ManifestPath::try_from(entry.path.as_str())
78                .map_err(|source| ManifestError::Path { index, source })?;
79            let kind = validate_kind(entry, index)?;
80            if let EntryKind::File { size, .. } = kind {
81                total = total
82                    .checked_add(size)
83                    .ok_or(ManifestError::SizeOverflow { index })?;
84            }
85            if paths.insert(path.as_str().to_lowercase(), index).is_some() {
86                return Err(ManifestError::DuplicatePath { index });
87            }
88            entries.push(Entry { path, kind });
89        }
90        validate_parents(&entries, &paths)?;
91        if total != manifest.total_size {
92            return Err(ManifestError::TotalSize);
93        }
94        Ok(Self { entries })
95    }
96}
97
98fn validate_kind(
99    entry: &a7n_proto::v1::FileEntry,
100    index: usize,
101) -> Result<EntryKind, ManifestError> {
102    match FileKind::try_from(entry.kind) {
103        Ok(FileKind::File) => Ok(EntryKind::File {
104            size: entry.size,
105            sha256: entry
106                .sha256
107                .as_slice()
108                .try_into()
109                .map_err(|_| ManifestError::Digest { index })?,
110        }),
111        Ok(FileKind::Directory) => {
112            if entry.size != 0 {
113                return Err(ManifestError::DirectorySize { index });
114            }
115            if !entry.sha256.is_empty() {
116                return Err(ManifestError::DirectoryDigest { index });
117            }
118            Ok(EntryKind::Directory)
119        }
120        _ => Err(ManifestError::Kind { index }),
121    }
122}
123
124fn validate_parents(
125    entries: &[Entry],
126    paths: &HashMap<String, usize>,
127) -> Result<(), ManifestError> {
128    for (index, entry) in entries.iter().enumerate() {
129        let folded = entry.path.as_str().to_lowercase();
130        for (end, _) in folded.match_indices('/') {
131            let Some(&parent) = paths.get(&folded[..end]) else {
132                continue;
133            };
134            if entries[parent].kind != EntryKind::Directory {
135                return Err(ManifestError::FileParent { index });
136            }
137            if parent > index {
138                return Err(ManifestError::ParentOrder { index });
139            }
140        }
141    }
142    Ok(())
143}
144
145#[derive(Debug, thiserror::Error, PartialEq, Eq)]
146pub enum NameError {
147    #[error(transparent)]
148    Path(#[from] PathError),
149    #[error("target name must be a single path segment")]
150    Nested,
151    #[error("target name search exhausted")]
152    Exhausted,
153}
154
155/// Choose a free top-level name; retain nested segments inside that renamed root.
156/// The caller must reserve the name and create files without following symlinks
157/// or overwriting existing files; this check alone cannot prevent filesystem races.
158///
159/// # Errors
160/// Rejects invalid or nested names, or exhaustion of the bounded name search.
161pub fn unique_name(name: &str, taken: impl Fn(&str) -> bool) -> Result<String, NameError> {
162    let path = ManifestPath::try_from(name)?;
163    if path.segments().count() != 1 {
164        return Err(NameError::Nested);
165    }
166    if !taken(name) {
167        return Ok(name.to_owned());
168    }
169    let (stem, extension) = name
170        .rsplit_once('.')
171        .filter(|(stem, _)| !stem.is_empty())
172        .map_or((name, String::new()), |(stem, ext)| {
173            (stem, format!(".{ext}"))
174        });
175    for number in 2..=NAME_ATTEMPTS_MAX {
176        let candidate = format!("{stem} ({number}){extension}");
177        if ManifestPath::try_from(candidate.as_str()).is_ok() && !taken(&candidate) {
178            return Ok(candidate);
179        }
180    }
181    Err(NameError::Exhausted)
182}
183
184/// Length measured by the daemon after re-hashing a partial file on disk.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub struct Partial {
187    pub file_index: u32,
188    pub len: u64,
189}
190
191/// Return plausible nonzero offsets; omitted entries restart at zero.
192/// A prefix hash cannot be checked against a full-file manifest hash, so final
193/// per-file SHA-256 verification remains mandatory even after resuming.
194#[must_use]
195pub fn resume_plan(manifest: &ValidManifest, partial: &[Partial]) -> Vec<FileResume> {
196    partial
197        .iter()
198        .filter_map(|part| {
199            let index = usize::try_from(part.file_index).ok()?;
200            let EntryKind::File { size, .. } = manifest.entries.get(index)?.kind else {
201                return None;
202            };
203            (part.len > 0 && part.len < size).then_some(FileResume {
204                file_index: part.file_index,
205                offset: part.len,
206            })
207        })
208        .collect()
209}
210
211#[cfg(test)]
212mod tests;