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 = 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; // 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(|| 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            // Other entry kinds (hardlinks, devices, fifos) are not part of the
224            // package surface and are dropped, matching the index scanner.
225        }
226    }
227    let mount_tar = builder
228        .into_inner()
229        .map_err(|e| VfsError::new("EIO", format!("finish repacked mount tar: {e}")))?;
230
231    // Pass 2: index the rebuilt tar.
232    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    // Peek at the agent block for the snapshot decision; the authoritative
252    // JSON-to-v1 conversion is shared with transition-directory projection.
253    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
467/// Derive command targets from a root npm `package.json` `bin` field. Shared
468/// with the sidecar's transition-dir projection so packed and dir packages
469/// derive identical command sets.
470pub 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}