1use std::collections::BTreeMap;
15use std::io::{Cursor, Read, Write};
16use std::path::Path;
17
18use super::generated::v1;
19use super::versioned::{encode_mount_index, encode_package_manifest};
20use super::{encode_aospkg_header, AOSPKG_HEADER_LEN};
21use crate::posix::vfs::{VfsError, VfsResult};
22
23pub const MANIFEST_JSON_NAME: &str = "agentos-package.json";
25pub const SNAPSHOT_BUNDLE_PATH: &str = "/dist/sdk-snapshot.js";
27pub const MAX_PACK_INDEX_ENTRIES: usize = 200_000;
31const S_IFDIR: u32 = 0o040000;
32const S_IFREG: u32 = 0o100000;
33const S_IFLNK: u32 = 0o120000;
34
35#[derive(Debug, Clone)]
37pub struct PackSummary {
38 pub name: String,
39 pub version: String,
40 pub commands: Vec<String>,
41}
42
43#[derive(serde::Deserialize)]
44struct SourceManifestJson {
45 #[serde(default)]
46 name: String,
47 #[serde(default)]
48 version: String,
49 #[serde(default)]
50 agent: Option<SourceAgentJson>,
51 #[serde(default)]
52 provides: Option<SourceProvidesJson>,
53}
54
55#[derive(serde::Deserialize)]
56struct SourceAgentJson {
57 #[serde(rename = "acpEntrypoint")]
58 acp_entrypoint: String,
59 #[serde(default)]
60 snapshot: bool,
61 #[serde(default)]
62 env: std::collections::HashMap<String, String>,
63 #[serde(default, rename = "launchArgs")]
64 launch_args: Vec<String>,
65}
66
67#[derive(serde::Deserialize)]
68struct SourceProvidesJson {
69 #[serde(default)]
70 env: std::collections::HashMap<String, String>,
71 #[serde(default)]
72 files: Vec<SourceProvidesFileJson>,
73}
74
75#[derive(serde::Deserialize)]
76struct SourceProvidesFileJson {
77 source: String,
78 target: String,
79}
80
81pub fn manifest_json_to_v1(
86 manifest_json: &[u8],
87 commands: Vec<v1::CommandTarget>,
88 man_pages: Vec<v1::ManPage>,
89 snapshot_bundle_path: Option<String>,
90) -> VfsResult<v1::PackageManifest> {
91 let source: SourceManifestJson = serde_json::from_slice(manifest_json)
92 .map_err(|e| VfsError::new("EINVAL", format!("invalid {MANIFEST_JSON_NAME}: {e}")))?;
93 if source.name.is_empty() {
94 return Err(VfsError::new(
95 "EINVAL",
96 format!("{MANIFEST_JSON_NAME} is missing a valid \"name\""),
97 ));
98 }
99 if source.version.is_empty() {
100 return Err(VfsError::new(
101 "EINVAL",
102 format!("{MANIFEST_JSON_NAME} is missing a valid \"version\""),
103 ));
104 }
105 Ok(v1::PackageManifest {
106 name: source.name,
107 version: source.version,
108 agent: source.agent.map(|agent| v1::AgentBlock {
109 acp_entrypoint: agent.acp_entrypoint,
110 snapshot: agent.snapshot,
111 env: agent.env,
112 launch_args: agent.launch_args,
113 }),
114 provides: source.provides.map(|provides| v1::ProvidesBlock {
115 env: provides.env,
116 files: provides
117 .files
118 .into_iter()
119 .map(|file| v1::ProvidesFile {
120 source: file.source,
121 target: file.target,
122 })
123 .collect(),
124 }),
125 commands,
126 man_pages,
127 snapshot_bundle_path,
128 })
129}
130
131#[derive(Clone)]
132struct IndexedEntry {
133 kind: v1::TarEntryKind,
134 offset: u64,
135 size: u64,
136 mode: u32,
137 uid: u32,
138 gid: u32,
139 mtime: i64,
140 link_target: Option<String>,
141}
142
143pub fn pack_aospkg_from_tar(source_tar: &Path, dest: &Path) -> VfsResult<PackSummary> {
146 let source_bytes = std::fs::read(source_tar).map_err(|e| {
147 VfsError::new(
148 "EIO",
149 format!("read source tar {}: {e}", source_tar.display()),
150 )
151 })?;
152 let (aospkg_bytes, summary) = pack_aospkg_from_tar_bytes(&source_bytes)?;
153 std::fs::write(dest, aospkg_bytes)
154 .map_err(|e| VfsError::new("EIO", format!("write {}: {e}", dest.display())))?;
155 Ok(summary)
156}
157
158pub fn pack_aospkg_from_tar_bytes(source_tar: &[u8]) -> VfsResult<(Vec<u8>, PackSummary)> {
160 let mut manifest_json = None::<Vec<u8>>;
164 let mut package_json = None::<Vec<u8>>;
165 let mut builder = tar::Builder::new(Vec::<u8>::new());
166 {
167 let mut archive = tar::Archive::new(Cursor::new(source_tar));
168 for entry in archive
169 .entries()
170 .map_err(|e| VfsError::new("EINVAL", format!("read source tar entries: {e}")))?
171 {
172 let mut entry = entry
173 .map_err(|e| VfsError::new("EINVAL", format!("read source tar entry: {e}")))?;
174 let path = canonical_tar_path_of(&entry)?;
175 let header = entry.header().clone();
176 let entry_type = header.entry_type();
177 if path == "/" {
178 continue;
179 }
180 if path == format!("/{MANIFEST_JSON_NAME}") {
181 let mut bytes = Vec::new();
182 entry
183 .read_to_end(&mut bytes)
184 .map_err(|e| VfsError::new("EIO", format!("read {MANIFEST_JSON_NAME}: {e}")))?;
185 manifest_json = Some(bytes);
186 continue; }
188 let rel = path.trim_start_matches('/').to_owned();
189 if entry_type.is_dir() {
190 let mut out = header.clone();
191 out.set_size(0);
195 builder
196 .append_data(&mut out, &rel, std::io::empty())
197 .map_err(|e| VfsError::new("EIO", format!("repack dir {rel}: {e}")))?;
198 } else if entry_type.is_symlink() {
199 let target = entry
200 .link_name()
201 .map_err(|e| VfsError::new("EINVAL", format!("symlink target {rel}: {e}")))?
202 .ok_or_else(|| VfsError::new("EINVAL", format!("symlink {rel} has no target")))?
203 .into_owned();
204 let mut out = header.clone();
205 out.set_size(0);
206 builder
207 .append_link(&mut out, &rel, &target)
208 .map_err(|e| VfsError::new("EIO", format!("repack symlink {rel}: {e}")))?;
209 } else if entry_type.is_file() || entry_type == tar::EntryType::Continuous {
210 let mut bytes = Vec::with_capacity(header.size().unwrap_or(0) as usize);
211 entry
212 .read_to_end(&mut bytes)
213 .map_err(|e| VfsError::new("EIO", format!("read member {rel}: {e}")))?;
214 if path == "/package.json" {
215 package_json = Some(bytes.clone());
216 }
217 let mut out = header.clone();
218 out.set_size(bytes.len() as u64);
219 builder
220 .append_data(&mut out, &rel, Cursor::new(bytes))
221 .map_err(|e| VfsError::new("EIO", format!("repack file {rel}: {e}")))?;
222 }
223 }
226 }
227 let mount_tar = builder
228 .into_inner()
229 .map_err(|e| VfsError::new("EIO", format!("finish repacked mount tar: {e}")))?;
230
231 let entries = scan_tar_index(&mount_tar)?;
233 if entries.len() > MAX_PACK_INDEX_ENTRIES {
234 return Err(VfsError::new(
235 "EOVERFLOW",
236 format!(
237 "package mount index has {} entries > MAX_PACK_INDEX_ENTRIES ({MAX_PACK_INDEX_ENTRIES}); \
238 the load-side TarFileSystem cap would reject this package at VM configure — \
239 split the package or raise both limits together",
240 entries.len()
241 ),
242 ));
243 }
244
245 let manifest_json = manifest_json.ok_or_else(|| {
246 VfsError::new(
247 "EINVAL",
248 format!("source tar must contain /{MANIFEST_JSON_NAME}"),
249 )
250 })?;
251 let source: SourceManifestJson = serde_json::from_slice(&manifest_json)
254 .map_err(|e| VfsError::new("EINVAL", format!("invalid {MANIFEST_JSON_NAME}: {e}")))?;
255 let commands = command_targets(&entries, package_json.as_deref());
256 let man_pages = man_pages_from_index(&entries);
257 let snapshot_bundle_path = source
258 .agent
259 .as_ref()
260 .filter(|agent| agent.snapshot)
261 .and_then(|_| {
262 entries
263 .contains_key(SNAPSHOT_BUNDLE_PATH)
264 .then(|| SNAPSHOT_BUNDLE_PATH.to_owned())
265 });
266
267 let command_names = commands
268 .iter()
269 .map(|target| target.command.clone())
270 .collect::<Vec<_>>();
271 let manifest = manifest_json_to_v1(&manifest_json, commands, man_pages, snapshot_bundle_path)?;
272 let (name, version) = (manifest.name.clone(), manifest.version.clone());
273
274 let tar_entries = entries
275 .into_iter()
276 .map(|(path, entry)| v1::TarEntry {
277 path,
278 kind: entry.kind,
279 offset: entry.offset,
280 size: entry.size,
281 mode: entry.mode,
282 uid: entry.uid,
283 gid: entry.gid,
284 mtime: entry.mtime,
285 link_target: entry.link_target,
286 })
287 .collect();
288
289 let manifest_bytes = encode_package_manifest(manifest)
290 .map_err(|e| VfsError::new("EINVAL", format!("encode package manifest: {e}")))?;
291 let index_bytes = encode_mount_index(v1::MountIndex { tar_entries })
292 .map_err(|e| VfsError::new("EINVAL", format!("encode mount index: {e}")))?;
293 let header = encode_aospkg_header(manifest_bytes.len(), index_bytes.len())?;
294
295 let mut out = Vec::with_capacity(
296 AOSPKG_HEADER_LEN + manifest_bytes.len() + index_bytes.len() + mount_tar.len(),
297 );
298 out.write_all(&header).expect("vec write");
299 out.write_all(&manifest_bytes).expect("vec write");
300 out.write_all(&index_bytes).expect("vec write");
301 out.write_all(&mount_tar).expect("vec write");
302 Ok((
303 out,
304 PackSummary {
305 name,
306 version,
307 commands: command_names,
308 },
309 ))
310}
311
312fn scan_tar_index(mount_tar: &[u8]) -> VfsResult<BTreeMap<String, IndexedEntry>> {
313 let mut archive = tar::Archive::new(Cursor::new(mount_tar));
314 let mut entries = BTreeMap::<String, IndexedEntry>::new();
315 for entry in archive
316 .entries()
317 .map_err(|e| VfsError::new("EINVAL", format!("scan repacked tar: {e}")))?
318 {
319 let entry =
320 entry.map_err(|e| VfsError::new("EINVAL", format!("scan repacked tar entry: {e}")))?;
321 let path = canonical_tar_path_of(&entry)?;
322 if path == "/" {
323 continue;
324 }
325 let header = entry.header();
326 let entry_type = header.entry_type();
327 let mode = header.mode().unwrap_or(0o755) & 0o7777;
328 let uid = header.uid().unwrap_or(0) as u32;
329 let gid = header.gid().unwrap_or(0) as u32;
330 let mtime = header.mtime().unwrap_or(0) as i64;
331 let size = header.size().unwrap_or(0);
332 let indexed = if entry_type.is_dir() {
333 Some(IndexedEntry {
334 kind: v1::TarEntryKind::Directory,
335 offset: 0,
336 size: 0,
337 mode: S_IFDIR | mode,
338 uid,
339 gid,
340 mtime,
341 link_target: None,
342 })
343 } else if entry_type.is_symlink() {
344 let target = entry
345 .link_name()
346 .map_err(|e| VfsError::new("EINVAL", format!("symlink target {path}: {e}")))?
347 .ok_or_else(|| VfsError::new("EINVAL", format!("symlink {path} has no target")))?
348 .to_string_lossy()
349 .into_owned();
350 Some(IndexedEntry {
351 kind: v1::TarEntryKind::Symlink,
352 offset: 0,
353 size: 0,
354 mode: S_IFLNK | mode.max(0o777),
355 uid,
356 gid,
357 mtime,
358 link_target: Some(target),
359 })
360 } else if entry_type.is_file() || entry_type == tar::EntryType::Continuous {
361 Some(IndexedEntry {
362 kind: v1::TarEntryKind::File,
363 offset: entry.raw_file_position(),
364 size,
365 mode: S_IFREG | mode,
366 uid,
367 gid,
368 mtime,
369 link_target: None,
370 })
371 } else {
372 None
373 };
374 if let Some(indexed) = indexed {
375 synthesize_parent_dirs(&path, &mut entries);
376 entries.insert(path, indexed);
377 }
378 }
379 entries.entry(String::from("/")).or_insert(IndexedEntry {
380 kind: v1::TarEntryKind::Directory,
381 offset: 0,
382 size: 0,
383 mode: S_IFDIR | 0o755,
384 uid: 0,
385 gid: 0,
386 mtime: 0,
387 link_target: None,
388 });
389 Ok(entries)
390}
391
392fn canonical_tar_path_of<R: Read>(entry: &tar::Entry<'_, R>) -> VfsResult<String> {
393 let path = entry
394 .path()
395 .map_err(|e| VfsError::new("EINVAL", format!("read tar member path: {e}")))?;
396 let mut parts = Vec::new();
397 for component in path.components() {
398 match component {
399 std::path::Component::Normal(value) => parts.push(value.to_string_lossy().into_owned()),
400 std::path::Component::CurDir => {}
401 _ => {
402 return Err(VfsError::new(
403 "EINVAL",
404 format!("tar member path escapes root: {}", path.display()),
405 ))
406 }
407 }
408 }
409 if parts.is_empty() {
410 Ok(String::from("/"))
411 } else {
412 Ok(format!("/{}", parts.join("/")))
413 }
414}
415
416fn synthesize_parent_dirs(path: &str, entries: &mut BTreeMap<String, IndexedEntry>) {
417 let components = path
418 .trim_start_matches('/')
419 .split('/')
420 .filter(|part| !part.is_empty())
421 .collect::<Vec<_>>();
422 let mut current = String::from("/");
423 for component in components.iter().take(components.len().saturating_sub(1)) {
424 current = if current == "/" {
425 format!("/{component}")
426 } else {
427 format!("{current}/{component}")
428 };
429 entries.entry(current.clone()).or_insert(IndexedEntry {
430 kind: v1::TarEntryKind::Directory,
431 offset: 0,
432 size: 0,
433 mode: S_IFDIR | 0o755,
434 uid: 0,
435 gid: 0,
436 mtime: 0,
437 link_target: None,
438 });
439 }
440}
441
442fn command_targets(
443 entries: &BTreeMap<String, IndexedEntry>,
444 package_json: Option<&[u8]>,
445) -> Vec<v1::CommandTarget> {
446 if let Some(bytes) = package_json {
447 if let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) {
448 if let Some(targets) = command_targets_from_package_json(&value) {
449 return targets;
450 }
451 }
452 }
453 let mut commands = entries
454 .keys()
455 .filter_map(|path| {
456 let name = path.strip_prefix("/bin/")?;
457 (!name.contains('/') && is_projectable_command_name(name)).then(|| v1::CommandTarget {
458 command: name.to_owned(),
459 entry: format!("bin/{name}"),
460 })
461 })
462 .collect::<Vec<_>>();
463 commands.sort_by(|a, b| a.command.cmp(&b.command));
464 commands
465}
466
467pub fn command_targets_from_package_json(
471 value: &serde_json::Value,
472) -> Option<Vec<v1::CommandTarget>> {
473 match value.get("bin") {
474 Some(serde_json::Value::String(path)) => {
475 let name = value.get("name").and_then(|v| v.as_str())?;
476 let unscoped = name.rsplit('/').next().unwrap_or(name).to_owned();
477 Some(
478 is_projectable_command_name(&unscoped)
479 .then(|| v1::CommandTarget {
480 command: unscoped,
481 entry: normalize_rel(path),
482 })
483 .into_iter()
484 .collect(),
485 )
486 }
487 Some(serde_json::Value::Object(map)) => {
488 let mut targets = map
489 .iter()
490 .filter_map(|(name, path)| {
491 is_projectable_command_name(name)
492 .then(|| path.as_str())
493 .flatten()
494 .map(|path| v1::CommandTarget {
495 command: name.clone(),
496 entry: normalize_rel(path),
497 })
498 })
499 .collect::<Vec<_>>();
500 targets.sort_by(|a, b| a.command.cmp(&b.command));
501 Some(targets)
502 }
503 _ => None,
504 }
505}
506
507fn man_pages_from_index(entries: &BTreeMap<String, IndexedEntry>) -> Vec<v1::ManPage> {
508 let mut pages = entries
509 .keys()
510 .filter_map(|path| {
511 let suffix = path.strip_prefix("/share/man/")?;
512 let (section, page) = suffix.split_once('/')?;
513 (!page.contains('/')).then(|| v1::ManPage {
514 section: section.to_owned(),
515 page: page.to_owned(),
516 })
517 })
518 .collect::<Vec<_>>();
519 pages.sort_by(|a, b| (&a.section, &a.page).cmp(&(&b.section, &b.page)));
520 pages
521}
522
523pub fn is_projectable_command_name(name: &str) -> bool {
524 !name.starts_with('_') && !name.starts_with('.')
525}
526
527fn normalize_rel(path: &str) -> String {
528 path.strip_prefix("./").unwrap_or(path).to_owned()
529}