Skip to main content

vfs/package_format/
pack.rs

1//! Canonical `.aospkg` packer: source `package.tar` → `header + vbare manifest
2//! + vbare mount index + filtered mount.tar`.
3//!
4//! `agentos-package.json` is a **pack-time input only**. It is parsed here to
5//! build the chunk1 vbare `PackageManifest` and then stripped from the packed
6//! mount tar — the vbare manifest is the single runtime manifest, and nothing
7//! materializes JSON back into the guest. A root `package.json` (npm) stays in
8//! the mount tar because Node module resolution may need it at runtime; its
9//! `bin` field is only consulted to derive command targets.
10//!
11//! Packing happens once at package build time (toolchain / bench / test
12//! fixtures). It is never on the VM load path.
13
14use 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
23/// The toolchain-input manifest name. Pack-time input only; never shipped.
24pub const MANIFEST_JSON_NAME: &str = "agentos-package.json";
25/// Canonical in-package snapshot bundle path for snapshot-enabled agents.
26pub const SNAPSHOT_BUNDLE_PATH: &str = "/dist/sdk-snapshot.js";
27/// Pack-time mirror of the load-side index cap in `posix::tar_fs`
28/// (`MAX_TAR_INDEX_ENTRIES`): fail at pack time, where the limit can be fixed,
29/// instead of at every consumer's VM configure.
30pub 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/// Result of packing: identity + derived command names (sorted).
36#[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
81/// Parse toolchain-input `agentos-package.json` bytes into a chunk1
82/// `PackageManifest`, with commands/man-pages/snapshot path supplied by the
83/// caller (packed: derived from the mount index; transition dir: from a dir
84/// scan). One JSON schema definition serves both pipelines.
85pub 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
143/// Pack the source tar at `source_tar` into a `.aospkg` at `dest`. The source
144/// `agentos-package.json` must carry `name` and `version`.
145pub 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
158/// In-memory variant of [`pack_aospkg_from_tar`].
159pub fn pack_aospkg_from_tar_bytes(source_tar: &[u8]) -> VfsResult<(Vec<u8>, PackSummary)> {
160    // Pass 1: read every entry, capture the pack-time JSON inputs, and rebuild
161    // the mount tar WITHOUT agentos-package.json. Index offsets must refer to
162    // the rebuilt tar, so it is written first and scanned second.
163    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; // stripped: the vbare manifest is the runtime manifest
187            }
188            let rel = path.trim_start_matches('/').to_owned();
189            if entry_type.is_dir() {
190                let mut out = header.clone();
191                // Some producers record a nonzero size on dir headers; zero it
192                // or the rebuilt tar frames N phantom data blocks and every
193                // subsequent index offset is wrong.
194                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            // Other entry kinds (hardlinks, devices, fifos) are not part of the
226            // package surface and are dropped, matching the index scanner.
227        }
228    }
229    let mount_tar = builder
230        .into_inner()
231        .map_err(|e| VfsError::new("EIO", format!("finish repacked mount tar: {e}")))?;
232
233    // Pass 2: index the rebuilt tar.
234    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    // Peek at the agent block for the snapshot decision; the authoritative
254    // JSON-to-v1 conversion is the shared `manifest_json_to_v1`.
255    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
472/// Derive command targets from a root npm `package.json` `bin` field. Shared
473/// with the sidecar's transition-dir projection so packed and dir packages
474/// derive identical command sets.
475pub 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}