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 =
173 entry.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.read_to_end(&mut bytes).map_err(|e| {
183 VfsError::new("EIO", format!("read {MANIFEST_JSON_NAME}: {e}"))
184 })?;
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(|| {
203 VfsError::new("EINVAL", format!("symlink {rel} has no target"))
204 })?
205 .into_owned();
206 let mut out = header.clone();
207 out.set_size(0);
208 builder
209 .append_link(&mut out, &rel, &target)
210 .map_err(|e| VfsError::new("EIO", format!("repack symlink {rel}: {e}")))?;
211 } else if entry_type.is_file() || entry_type == tar::EntryType::Continuous {
212 let mut bytes = Vec::with_capacity(header.size().unwrap_or(0) as usize);
213 entry
214 .read_to_end(&mut bytes)
215 .map_err(|e| VfsError::new("EIO", format!("read member {rel}: {e}")))?;
216 if path == "/package.json" {
217 package_json = Some(bytes.clone());
218 }
219 let mut out = header.clone();
220 out.set_size(bytes.len() as u64);
221 builder
222 .append_data(&mut out, &rel, Cursor::new(bytes))
223 .map_err(|e| VfsError::new("EIO", format!("repack file {rel}: {e}")))?;
224 }
225 }
228 }
229 let mount_tar = builder
230 .into_inner()
231 .map_err(|e| VfsError::new("EIO", format!("finish repacked mount tar: {e}")))?;
232
233 let entries = scan_tar_index(&mount_tar)?;
235 if entries.len() > MAX_PACK_INDEX_ENTRIES {
236 return Err(VfsError::new(
237 "EOVERFLOW",
238 format!(
239 "package mount index has {} entries > MAX_PACK_INDEX_ENTRIES ({MAX_PACK_INDEX_ENTRIES}); \
240 the load-side TarFileSystem cap would reject this package at VM configure — \
241 split the package or raise both limits together",
242 entries.len()
243 ),
244 ));
245 }
246
247 let manifest_json = manifest_json.ok_or_else(|| {
248 VfsError::new(
249 "EINVAL",
250 format!("source tar must contain /{MANIFEST_JSON_NAME}"),
251 )
252 })?;
253 let source: SourceManifestJson = serde_json::from_slice(&manifest_json)
256 .map_err(|e| VfsError::new("EINVAL", format!("invalid {MANIFEST_JSON_NAME}: {e}")))?;
257 let commands = command_targets(&entries, package_json.as_deref());
258 let man_pages = man_pages_from_index(&entries);
259 let snapshot_bundle_path = source
260 .agent
261 .as_ref()
262 .filter(|agent| agent.snapshot)
263 .and_then(|_| {
264 entries
265 .contains_key(SNAPSHOT_BUNDLE_PATH)
266 .then(|| SNAPSHOT_BUNDLE_PATH.to_owned())
267 });
268
269 let command_names = commands
270 .iter()
271 .map(|target| target.command.clone())
272 .collect::<Vec<_>>();
273 let manifest = manifest_json_to_v1(&manifest_json, commands, man_pages, snapshot_bundle_path)?;
274 let (name, version) = (manifest.name.clone(), manifest.version.clone());
275
276 let tar_entries = entries
277 .into_iter()
278 .map(|(path, entry)| v1::TarEntry {
279 path,
280 kind: entry.kind,
281 offset: entry.offset,
282 size: entry.size,
283 mode: entry.mode,
284 uid: entry.uid,
285 gid: entry.gid,
286 mtime: entry.mtime,
287 link_target: entry.link_target,
288 })
289 .collect();
290
291 let manifest_bytes = encode_package_manifest(manifest)
292 .map_err(|e| VfsError::new("EINVAL", format!("encode package manifest: {e}")))?;
293 let index_bytes = encode_mount_index(v1::MountIndex { tar_entries })
294 .map_err(|e| VfsError::new("EINVAL", format!("encode mount index: {e}")))?;
295 let header = encode_aospkg_header(manifest_bytes.len(), index_bytes.len())?;
296
297 let mut out =
298 Vec::with_capacity(AOSPKG_HEADER_LEN + manifest_bytes.len() + index_bytes.len() + mount_tar.len());
299 out.write_all(&header).expect("vec write");
300 out.write_all(&manifest_bytes).expect("vec write");
301 out.write_all(&index_bytes).expect("vec write");
302 out.write_all(&mount_tar).expect("vec write");
303 Ok((
304 out,
305 PackSummary {
306 name,
307 version,
308 commands: command_names,
309 },
310 ))
311}
312
313fn scan_tar_index(mount_tar: &[u8]) -> VfsResult<BTreeMap<String, IndexedEntry>> {
314 let mut archive = tar::Archive::new(Cursor::new(mount_tar));
315 let mut entries = BTreeMap::<String, IndexedEntry>::new();
316 for entry in archive
317 .entries()
318 .map_err(|e| VfsError::new("EINVAL", format!("scan repacked tar: {e}")))?
319 {
320 let entry =
321 entry.map_err(|e| VfsError::new("EINVAL", format!("scan repacked tar entry: {e}")))?;
322 let path = canonical_tar_path_of(&entry)?;
323 if path == "/" {
324 continue;
325 }
326 let header = entry.header();
327 let entry_type = header.entry_type();
328 let mode = header.mode().unwrap_or(0o755) & 0o7777;
329 let uid = header.uid().unwrap_or(0) as u32;
330 let gid = header.gid().unwrap_or(0) as u32;
331 let mtime = header.mtime().unwrap_or(0) as i64;
332 let size = header.size().unwrap_or(0);
333 let indexed = if entry_type.is_dir() {
334 Some(IndexedEntry {
335 kind: v1::TarEntryKind::Directory,
336 offset: 0,
337 size: 0,
338 mode: S_IFDIR | mode,
339 uid,
340 gid,
341 mtime,
342 link_target: None,
343 })
344 } else if entry_type.is_symlink() {
345 let target = entry
346 .link_name()
347 .map_err(|e| VfsError::new("EINVAL", format!("symlink target {path}: {e}")))?
348 .ok_or_else(|| VfsError::new("EINVAL", format!("symlink {path} has no target")))?
349 .to_string_lossy()
350 .into_owned();
351 Some(IndexedEntry {
352 kind: v1::TarEntryKind::Symlink,
353 offset: 0,
354 size: 0,
355 mode: S_IFLNK | mode.max(0o777),
356 uid,
357 gid,
358 mtime,
359 link_target: Some(target),
360 })
361 } else if entry_type.is_file() || entry_type == tar::EntryType::Continuous {
362 Some(IndexedEntry {
363 kind: v1::TarEntryKind::File,
364 offset: entry.raw_file_position(),
365 size,
366 mode: S_IFREG | mode,
367 uid,
368 gid,
369 mtime,
370 link_target: None,
371 })
372 } else {
373 None
374 };
375 if let Some(indexed) = indexed {
376 synthesize_parent_dirs(&path, &mut entries);
377 entries.insert(path, indexed);
378 }
379 }
380 entries.entry(String::from("/")).or_insert(IndexedEntry {
381 kind: v1::TarEntryKind::Directory,
382 offset: 0,
383 size: 0,
384 mode: S_IFDIR | 0o755,
385 uid: 0,
386 gid: 0,
387 mtime: 0,
388 link_target: None,
389 });
390 Ok(entries)
391}
392
393fn canonical_tar_path_of<R: Read>(entry: &tar::Entry<'_, R>) -> VfsResult<String> {
394 let path = entry
395 .path()
396 .map_err(|e| VfsError::new("EINVAL", format!("read tar member path: {e}")))?;
397 let mut parts = Vec::new();
398 for component in path.components() {
399 match component {
400 std::path::Component::Normal(value) => {
401 parts.push(value.to_string_lossy().into_owned())
402 }
403 std::path::Component::CurDir => {}
404 _ => {
405 return Err(VfsError::new(
406 "EINVAL",
407 format!("tar member path escapes root: {}", path.display()),
408 ))
409 }
410 }
411 }
412 if parts.is_empty() {
413 Ok(String::from("/"))
414 } else {
415 Ok(format!("/{}", parts.join("/")))
416 }
417}
418
419fn synthesize_parent_dirs(path: &str, entries: &mut BTreeMap<String, IndexedEntry>) {
420 let components = path
421 .trim_start_matches('/')
422 .split('/')
423 .filter(|part| !part.is_empty())
424 .collect::<Vec<_>>();
425 let mut current = String::from("/");
426 for component in components.iter().take(components.len().saturating_sub(1)) {
427 current = if current == "/" {
428 format!("/{component}")
429 } else {
430 format!("{current}/{component}")
431 };
432 entries.entry(current.clone()).or_insert(IndexedEntry {
433 kind: v1::TarEntryKind::Directory,
434 offset: 0,
435 size: 0,
436 mode: S_IFDIR | 0o755,
437 uid: 0,
438 gid: 0,
439 mtime: 0,
440 link_target: None,
441 });
442 }
443}
444
445fn command_targets(
446 entries: &BTreeMap<String, IndexedEntry>,
447 package_json: Option<&[u8]>,
448) -> Vec<v1::CommandTarget> {
449 if let Some(bytes) = package_json {
450 if let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) {
451 if let Some(targets) = command_targets_from_package_json(&value) {
452 return targets;
453 }
454 }
455 }
456 let mut commands = entries
457 .keys()
458 .filter_map(|path| {
459 let name = path.strip_prefix("/bin/")?;
460 (!name.contains('/') && is_projectable_command_name(name)).then(|| {
461 v1::CommandTarget {
462 command: name.to_owned(),
463 entry: format!("bin/{name}"),
464 }
465 })
466 })
467 .collect::<Vec<_>>();
468 commands.sort_by(|a, b| a.command.cmp(&b.command));
469 commands
470}
471
472pub fn command_targets_from_package_json(
476 value: &serde_json::Value,
477) -> Option<Vec<v1::CommandTarget>> {
478 match value.get("bin") {
479 Some(serde_json::Value::String(path)) => {
480 let name = value.get("name").and_then(|v| v.as_str())?;
481 let unscoped = name.rsplit('/').next().unwrap_or(name).to_owned();
482 Some(
483 is_projectable_command_name(&unscoped)
484 .then(|| v1::CommandTarget {
485 command: unscoped,
486 entry: normalize_rel(path),
487 })
488 .into_iter()
489 .collect(),
490 )
491 }
492 Some(serde_json::Value::Object(map)) => {
493 let mut targets = map
494 .iter()
495 .filter_map(|(name, path)| {
496 is_projectable_command_name(name)
497 .then(|| path.as_str())
498 .flatten()
499 .map(|path| v1::CommandTarget {
500 command: name.clone(),
501 entry: normalize_rel(path),
502 })
503 })
504 .collect::<Vec<_>>();
505 targets.sort_by(|a, b| a.command.cmp(&b.command));
506 Some(targets)
507 }
508 _ => None,
509 }
510}
511
512fn man_pages_from_index(entries: &BTreeMap<String, IndexedEntry>) -> Vec<v1::ManPage> {
513 let mut pages = entries
514 .keys()
515 .filter_map(|path| {
516 let suffix = path.strip_prefix("/share/man/")?;
517 let (section, page) = suffix.split_once('/')?;
518 (!page.contains('/')).then(|| v1::ManPage {
519 section: section.to_owned(),
520 page: page.to_owned(),
521 })
522 })
523 .collect::<Vec<_>>();
524 pages.sort_by(|a, b| (&a.section, &a.page).cmp(&(&b.section, &b.page)));
525 pages
526}
527
528pub fn is_projectable_command_name(name: &str) -> bool {
529 !name.starts_with('_') && !name.starts_with('.')
530}
531
532fn normalize_rel(path: &str) -> String {
533 path.strip_prefix("./").unwrap_or(path).to_owned()
534}