appcore_update/
catalog_store.rs1use crate::catalog::{ReleaseCatalog, RELEASE_CATALOG_MAX_BYTES, RELEASE_CATALOG_MAX_ENTRIES};
14use crate::filesystem::{open_regular_file, read_regular_file_bounded};
15use crate::stream::{ArtifactSource, DEFAULT_ARTIFACT_CHUNK_BYTES};
16use crate::{ArtifactAuthenticityVerifier, ArtifactDescriptor, UpdateError, UpdateResult};
17use serde::{Deserialize, Serialize};
18use std::collections::BTreeMap;
19use std::io::{Read, Seek, SeekFrom};
20use std::path::{Component, Path, PathBuf};
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct CatalogEntry {
25 pub descriptor: ArtifactDescriptor,
27 pub relative_path: String,
29}
30
31#[derive(Debug, Clone)]
33pub struct ReleaseCatalogStore {
34 root: PathBuf,
35 catalog: ReleaseCatalog,
36 entries: BTreeMap<String, CatalogEntry>,
37}
38
39impl ReleaseCatalogStore {
40 pub fn open(
42 root: impl AsRef<Path>,
43 catalog_path: impl AsRef<Path>,
44 verifier: &dyn ArtifactAuthenticityVerifier,
45 ) -> UpdateResult<Self> {
46 let root = canonical_directory(root.as_ref())?;
47 let catalog_path = resolve_safe_path(&root, catalog_path.as_ref())?;
48 let bytes = read_regular_file_bounded(&catalog_path, RELEASE_CATALOG_MAX_BYTES)
49 .map_err(|error| UpdateError::Store(error.to_string()))?;
50 let entries: Vec<CatalogEntry> = serde_json::from_slice(&bytes)
51 .map_err(|error| UpdateError::Store(error.to_string()))?;
52 if entries.len() > RELEASE_CATALOG_MAX_ENTRIES {
53 return Err(UpdateError::Store(
54 "release catalog exceeds entry limit".to_string(),
55 ));
56 }
57 let descriptors = entries
58 .iter()
59 .map(|entry| {
60 validate_relative_path(&entry.relative_path)?;
61 resolve_safe_path(&root, Path::new(&entry.relative_path))?;
62 Ok(entry.descriptor.clone())
63 })
64 .collect::<UpdateResult<Vec<_>>>()?;
65 let catalog = ReleaseCatalog::from_entries(descriptors, verifier)?;
66 let mut indexed = BTreeMap::new();
67 for entry in entries {
68 if indexed
69 .insert(entry.descriptor.sha256().to_string(), entry)
70 .is_some()
71 {
72 return Err(UpdateError::InvalidArtifact(
73 "catalog contains ambiguous duplicate artifact hashes".to_string(),
74 ));
75 }
76 }
77 Ok(Self {
78 root,
79 catalog,
80 entries: indexed,
81 })
82 }
83
84 pub fn catalog(&self) -> &ReleaseCatalog {
86 &self.catalog
87 }
88
89 pub fn len(&self) -> usize {
91 self.entries.len()
92 }
93
94 pub fn is_empty(&self) -> bool {
96 self.entries.is_empty()
97 }
98
99 fn entry_for(&self, descriptor: &ArtifactDescriptor) -> UpdateResult<&CatalogEntry> {
100 let entry = self
101 .entries
102 .get(descriptor.sha256())
103 .filter(|entry| entry.descriptor == *descriptor)
104 .ok_or_else(|| {
105 UpdateError::Provider(
106 "artifact descriptor is not present in the release catalog".to_string(),
107 )
108 })?;
109 Ok(entry)
110 }
111}
112
113impl ArtifactSource for ReleaseCatalogStore {
114 fn read_chunk(
115 &self,
116 descriptor: &ArtifactDescriptor,
117 offset: u64,
118 max_len: usize,
119 ) -> UpdateResult<Vec<u8>> {
120 if max_len == 0 || max_len > DEFAULT_ARTIFACT_CHUNK_BYTES {
121 return Err(UpdateError::Transfer(
122 "requested chunk exceeds the catalog source limit".to_string(),
123 ));
124 }
125 if offset >= descriptor.size_bytes() {
126 return Err(UpdateError::Transfer(
127 "artifact offset is outside the declared size".to_string(),
128 ));
129 }
130 let entry = self.entry_for(descriptor)?;
131 let path = resolve_safe_path(&self.root, Path::new(&entry.relative_path))?;
132 let mut file =
133 open_regular_file(&path).map_err(|error| UpdateError::Provider(error.to_string()))?;
134 if file
135 .metadata()
136 .map_err(|error| UpdateError::Provider(error.to_string()))?
137 .len()
138 != descriptor.size_bytes()
139 {
140 return Err(UpdateError::ChecksumMismatch);
141 }
142 file.seek(SeekFrom::Start(offset))
143 .map_err(|error| UpdateError::Provider(error.to_string()))?;
144 let remaining = descriptor.size_bytes() - offset;
145 let capacity = usize::try_from(remaining).unwrap_or(max_len).min(max_len);
146 let mut bytes = vec![0_u8; capacity];
147 file.read_exact(&mut bytes)
148 .map_err(|error| UpdateError::Provider(error.to_string()))?;
149 Ok(bytes)
150 }
151}
152
153fn canonical_directory(path: &Path) -> UpdateResult<PathBuf> {
154 let metadata =
155 std::fs::symlink_metadata(path).map_err(|error| UpdateError::Store(error.to_string()))?;
156 if metadata.file_type().is_symlink() || !metadata.is_dir() {
157 return Err(UpdateError::Store(
158 "catalog root is not a regular directory".to_string(),
159 ));
160 }
161 std::fs::canonicalize(path).map_err(|error| UpdateError::Store(error.to_string()))
162}
163
164fn resolve_safe_path(root: &Path, relative: &Path) -> UpdateResult<PathBuf> {
165 validate_relative_path(relative.to_string_lossy().as_ref())?;
166 let candidate = root.join(relative);
167 let canonical =
168 std::fs::canonicalize(&candidate).map_err(|error| UpdateError::Store(error.to_string()))?;
169 if !canonical.starts_with(root) {
170 return Err(UpdateError::Store(
171 "catalog path escapes its controlled directory".to_string(),
172 ));
173 }
174 Ok(canonical)
175}
176
177fn validate_relative_path(value: &str) -> UpdateResult<()> {
178 let path = Path::new(value);
179 if value.trim().is_empty() || value.len() > 1_024 || value.chars().any(char::is_control) {
180 return Err(UpdateError::InvalidArtifact(
181 "catalog relative path is invalid".to_string(),
182 ));
183 }
184 if path.is_absolute()
185 || path.components().any(|component| {
186 matches!(
187 component,
188 Component::Prefix(_)
189 | Component::RootDir
190 | Component::ParentDir
191 | Component::CurDir
192 )
193 })
194 {
195 return Err(UpdateError::InvalidArtifact(
196 "catalog relative path must stay below its root".to_string(),
197 ));
198 }
199 Ok(())
200}