harmos_packaging/bundle/
mod.rs1mod archive;
13mod error;
14mod manifest;
15mod metadata;
16
17pub use error::Error;
20pub use manifest::{Executable, Facility, File, Identity, Limits, Manifest, PackageMetadata};
21
22use std::collections::{BTreeMap, BTreeSet};
26use std::io::Write;
27use std::ops::Range;
28use std::path::Path;
29use harmos_domain::inventory::Declaration;
33
34pub struct Source<'a> {
43 pub path: &'a str,
44 pub bytes: &'a [u8],
45 pub executable: Option<Executable>,
46}
47
48pub struct Bundle {
49 manifest: Manifest,
50 bytes: Vec<u8>,
51 contents: BTreeMap<String, Range<usize>>,
52 declarations: BTreeMap<String, Declaration>,
53}
54
55impl Bundle {
62 #[tracing::instrument(level = "debug", skip_all)]
63 pub fn build(identity: Identity, sources: &[Source<'_>], limits: Limits) -> Result<Self, Error> {
64 if sources.len() > limits.files {
65 return Err(Error::Limit("file count"));
66 }
67 let mut files = Vec::with_capacity(sources.len());
68 let mut contents = BTreeMap::new();
69 let mut total = 0usize;
70 let mut metadata_bytes = metadata::identity_size(&identity, limits.manifest_bytes)?;
71 for source in sources {
72 archive::path(source.path)?;
73 total = total.checked_add(source.bytes.len()).ok_or(Error::Limit("total file bytes"))?;
74 if source.bytes.len() > limits.file_bytes || total > limits.total_file_bytes {
75 return Err(Error::Limit("file bytes"));
76 }
77 if contents.insert(source.path.to_owned(), source.bytes).is_some() {
78 return Err(invalid("duplicate file path"));
79 }
80 if source.executable.as_ref().is_some_and(|value| value.registrations.len() > limits.registrations) {
81 return Err(Error::Limit("registration count"));
82 }
83 let sha256 = crate::digest(source.bytes);
84 metadata::file_size(source, &sha256, !files.is_empty(), &mut metadata_bytes, limits.manifest_bytes)?;
85 let mut executable = source.executable.clone();
86 if let Some(executable) = &mut executable {
87 executable.registrations.sort_by(|left, right| left.namespace.cmp(&right.namespace));
88 }
89 files.push(File { path: source.path.to_owned(), size: source.bytes.len() as u64, sha256, executable });
90 }
91 files.sort_by(|left, right| left.path.cmp(&right.path));
92 let manifest = Manifest { schema: 1, identity, files };
93 validate(&manifest, limits)?;
94 let bytes = archive::encode(&manifest, &contents, limits)?;
95 Self::inspect(&bytes, limits)
96 }
97
98 #[tracing::instrument(level = "debug", skip_all)]
99 pub fn inspect(bytes: &[u8], limits: Limits) -> Result<Self, Error> {
100 let (manifest, contents) = archive::decode(bytes, limits)?;
101 let mut declarations = BTreeMap::new();
102 let mut owners = BTreeSet::new();
103 for file in &manifest.files {
104 let content = &bytes[contents[&file.path].clone()];
105 if let Some(executable) = &file.executable {
106 let facility = match executable.facility {
107 Facility::Sidecar => harmos_domain::inventory::Facility::Sidecar,
108 Facility::Guest => harmos_domain::inventory::Facility::Guest,
109 };
110 let declaration = Declaration::inspect(content, facility).map_err(|error| invalid(error.to_string()))?;
111 if !owners.insert((executable.facility, declaration.id.clone(), &executable.target)) {
112 return Err(invalid("duplicate executable facility, owner, and target"));
113 }
114 declarations.insert(file.path.clone(), declaration);
115 }
116 }
117 Ok(Self { manifest, bytes: bytes.to_vec(), contents, declarations })
118 }
119
120 pub fn manifest(&self) -> &Manifest {
121 &self.manifest
122 }
123 pub fn bytes(&self) -> &[u8] {
124 &self.bytes
125 }
126
127 pub fn declaration(&self, path: &str) -> Option<&Declaration> {
128 self.declarations.get(path)
129 }
130
131 pub fn file(&self, path: &str) -> Option<&[u8]> {
132 self.contents.get(path).map(|range| &self.bytes[range.clone()])
133 }
134
135 pub fn extract(&self, parent: &Path) -> Result<tempfile::TempDir, Error> {
136 let directory = archive::staging(parent)?;
137 for file in &self.manifest.files {
138 let path = directory.path().join(&file.path);
139 std::fs::create_dir_all(path.parent().expect("a file has a staging parent"))?;
140 let mut output = std::fs::OpenOptions::new().write(true).create_new(true).open(path)?;
141 output.write_all(self.file(&file.path).expect("verified file exists"))?;
142 archive::permissions(&output, file.executable.as_ref().is_some_and(|value| value.facility == Facility::Sidecar))?;
143 }
144 let path = directory.path().join("manifest.json");
145 let mut output = std::fs::OpenOptions::new().write(true).create_new(true).open(path)?;
146 serde_json::to_writer(&mut output, &self.manifest)?;
147 archive::permissions(&output, false)?;
148 Ok(directory)
149 }
150}
151
152fn invalid(message: impl Into<String>) -> Error {
157 Error::Invalid(message.into())
158}
159
160fn validate(manifest: &Manifest, limits: Limits) -> Result<(), Error> {
161 if manifest.schema != 1 {
162 return Err(invalid("unsupported schema"));
163 }
164 let name = &manifest.identity.name;
165 if name.is_empty() || !name.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) {
166 return Err(invalid("invalid Cargo package name"));
167 }
168 if manifest.files.len() > limits.files {
169 return Err(Error::Limit("file count"));
170 }
171 let mut paths = BTreeSet::new();
172 let mut previous = None;
173 for file in &manifest.files {
174 archive::path(&file.path)?;
175 if previous.is_some_and(|value: &str| value >= file.path.as_str()) {
176 return Err(invalid("files are not in unique path order"));
177 }
178 previous = Some(&file.path);
179 if !paths.insert(file.path.to_ascii_lowercase()) {
180 return Err(invalid("case-insensitive file collision"));
181 }
182 if let Some(executable) = &file.executable {
183 if executable.target.is_empty()
184 || executable.target.len() > 128
185 || !executable.target.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
186 {
187 return Err(invalid("invalid executable target"));
188 }
189 if executable.registrations.len() > limits.registrations {
190 return Err(Error::Limit("registration count"));
191 }
192 let mut previous = None;
193 for registration in &executable.registrations {
194 if !harmos_domain::artifact_subset(®istration.namespace)
195 || registration.schema == 0
196 || previous.is_some_and(|value: &str| value >= registration.namespace.as_str())
197 {
198 return Err(invalid("invalid, duplicate, or unordered registration"));
199 }
200 previous = Some(®istration.namespace);
201 serde_json::from_slice::<&serde_json::value::RawValue>(®istration.payload)?;
202 }
203 }
204 }
205 for path in &paths {
206 for (offset, _) in path.match_indices('/') {
207 if paths.contains(&path[..offset]) {
208 return Err(invalid("file path is another file's parent"));
209 }
210 }
211 }
212 Ok(())
213}
214
215