update docs

This commit is contained in:
2026-09-22 13:23:34 +08:00
parent 99bc3d15c5
commit 8a4788fca8
126 changed files with 7198 additions and 2425 deletions
+58 -19
View File
@@ -8,7 +8,7 @@ use fds_common::{
manifest::{Cartridge, Class, Manifest, Media},
read_text,
};
use fds_software::{Catalogue, archive};
use fds_software::{Catalogue, archive, tree};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
@@ -22,7 +22,8 @@ use std::{
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Payload {
bundles: Vec<PathBuf>,
/// Void software recipes or previously built installed-software directories.
sources: Vec<PathBuf>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
@@ -55,8 +56,16 @@ fn uuid(bytes: &[u8]) -> [u8; 16] {
id[8] = (id[8] & 63) | 0x80;
id
}
pub fn create(recipe: &Path, output: &Path, runner: Option<&Path>) -> Result<Inspection> {
pub fn create(
recipe: &Path,
output: &Path,
runner: Option<&Path>,
options: &crate::software::BuildOptions,
) -> Result<Inspection> {
workstation()?;
if output.try_exists()? {
return Err(Error("Cartridge output already exists".into()));
}
if unsafe { libc::geteuid() } == 0 {
return Err(Error(
"Create cartridge images as an ordinary workstation user".into(),
@@ -64,12 +73,12 @@ pub fn create(recipe: &Path, output: &Path, runner: Option<&Path>) -> Result<Ins
}
let plan: Recipe = toml::from_str(&read_text(recipe, 65536)?)
.map_err(|e| Error(format!("Invalid cartridge recipe: {e}")))?;
if plan.format != 1
if plan.format != 2
|| !(1..=32).contains(&plan.payload.len())
|| plan.payload.iter().any(|p| p.bundles.is_empty())
|| plan.payload.iter().any(|p| p.sources.is_empty())
{
return Err(Error(
"Cartridge recipe requires format 1 and 1..32 nonempty payload partitions".into(),
"Cartridge recipe requires format 2 and 1..32 nonempty payload partitions".into(),
));
}
let metadata = Manifest {
@@ -88,23 +97,33 @@ pub fn create(recipe: &Path, output: &Path, runner: Option<&Path>) -> Result<Ins
let base = parent(recipe)?;
let tree = work.0.join("metadata");
fs::create_dir_all(tree.join("FDS"))?;
for directory in [&tree, &tree.join("FDS")] {
fs::set_permissions(directory, fs::Permissions::from_mode(0o755))?;
}
fs::write(tree.join("FDS/CARTRIDGE.TOML"), metadata.to_toml()?)?;
let mut catalogue = Catalogue {
format: 1,
format: 2,
software: Vec::new(),
};
let mut trees = vec![tree.clone()];
for (index, part) in plan.payload.iter().enumerate() {
let tree = work.0.join(format!("payload{}", index + 2));
fs::create_dir_all(tree.join("bundles"))?;
for directory in &part.bundles {
let directory = base.join(directory).canonicalize()?;
fs::create_dir_all(tree.join("programs"))?;
for directory in [&tree, &tree.join("programs")] {
fs::set_permissions(directory, fs::Permissions::from_mode(0o755))?;
}
for (number, source) in part.sources.iter().enumerate() {
let source = base.join(source).canonicalize()?;
let directory = if source.is_dir() {
source
} else {
let directory = work.0.join(format!("installed-{index}-{number}"));
crate::software::build(&source, &directory, options)?;
directory
};
let mut entry = crate::software::load(&directory)?;
entry.partition = (index + 2) as u8;
fs::copy(
directory.join(format!("{}.tar.xz", entry.id)),
tree.join(entry.archive_path()),
)?;
tree::copy(&directory.join("root"), &tree.join(entry.root_path()))?;
catalogue.software.push(entry);
}
trees.push(tree);
@@ -192,7 +211,7 @@ pub fn inspect(path: &Path, runner: Option<&Path>) -> Result<Inspection> {
let mut info = image::inspect(&file, file.metadata()?.len())?;
if info.filesystem != "erofs" {
return Err(Error(
"Use fds-burn inspect for DATA geometry; this inspector validates software bundles"
"Use fds-burn inspect for DATA geometry; this inspector validates software cartridges"
.into(),
));
}
@@ -235,6 +254,13 @@ pub fn inspect(path: &Path, runner: Option<&Path>) -> Result<Inspection> {
}
for software in &catalogue.software {
let root = &trees[usize::from(software.partition) - 1];
if software.installed {
if !root.join("programs").symlink_metadata()?.is_dir() {
return Err(Error("Programs must be a real directory".into()));
}
tree::verify(&root.join(software.root_path()), software)?;
continue;
}
let directory = root.join("bundles");
if !directory.symlink_metadata()?.is_dir() {
return Err(Error("Bundle directory must not be a symlink".into()));
@@ -247,23 +273,36 @@ pub fn inspect(path: &Path, runner: Option<&Path>) -> Result<Inspection> {
}
// Reject unlisted files or software hidden in a payload partition.
for (index, root) in trees.iter().enumerate().skip(1) {
let directory = if catalogue.format == 2 {
"programs"
} else {
"bundles"
};
if fs::read_dir(root)?.count() != 1 {
return Err(Error("Payload partitions may contain only bundles/".into()));
return Err(Error(format!(
"Payload partitions may contain only {directory}/"
)));
}
let mut actual: Vec<_> = fs::read_dir(root.join("bundles"))?
let mut actual: Vec<_> = fs::read_dir(root.join(directory))?
.map(|e| e.map(|e| e.file_name()))
.collect::<std::io::Result<_>>()?;
let mut expected: Vec<_> = catalogue
.software
.iter()
.filter(|s| usize::from(s.partition) == index + 1)
.map(|s| std::ffi::OsString::from(format!("{}.tar.xz", s.id)))
.map(|s| {
std::ffi::OsString::from(if s.installed {
s.id.clone()
} else {
format!("{}.tar.xz", s.id)
})
})
.collect();
actual.sort();
expected.sort();
if actual != expected {
return Err(Error(
"Payload archive inventory disagrees with metadata".into(),
"Payload software inventory disagrees with metadata".into(),
));
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ pub fn cartridge(runner: Option<&Path>) -> Result<Value> {
)));
}
Ok(
json!({"xz":xz,"mkfs.erofs":mkfs,"fsck.erofs":fsck,"unprivileged_sandbox":"available","cross_compiler":"recipe-specific; use aarch64 output or architecture=any scripts"}),
json!({"xz_legacy_reader":xz,"mkfs.erofs":mkfs,"fsck.erofs":fsck,"unprivileged_sandbox":"available","software_builder":"prepared Void xbps-src checkout with native XBPS tools; builds aarch64 source packages and installs runtime dependencies"}),
)
}
pub fn emulator(runner: Option<&Path>) -> Result<Value> {
+16 -14
View File
@@ -5,18 +5,20 @@ use std::{path::PathBuf, process::ExitCode};
#[derive(Parser)]
#[command(
version,
about = "Build software bundles and metadata-first cartridge images on Linux"
about = "Build Void source packages and ready-to-run cartridge images on Linux"
)]
struct Cli {
/// Optional wrapper that accepts TOOL followed by its arguments.
#[arg(long, global = true)]
image_tool_runner: Option<PathBuf>,
#[command(flatten)]
build: fds_workstation::software::BuildOptions,
#[command(subcommand)]
command: Action,
}
#[derive(Subcommand)]
enum Action {
/// Check xz, erofs-utils and the unprivileged filesystem inspection sandbox.
/// Check EROFS tools, the inspection sandbox and legacy xz reader.
Doctor,
/// Build or package software on the workstation, never on the Pi.
Software {
@@ -25,7 +27,7 @@ enum Action {
},
/// Construct and verify a complete 1+m GPT cartridge image before burning.
Create { recipe: PathBuf, output: PathBuf },
/// Verify GPT, every filesystem, catalogue and xz tarball in a prepared image.
/// Verify GPT, every filesystem, catalogue and installed program tree.
Inspect { image: PathBuf },
/// Verify a prepared image and save an image/target-bound write preview.
Preview {
@@ -45,11 +47,9 @@ enum Action {
}
#[derive(Subcommand)]
enum Software {
/// Execute an explicit trusted workstation build recipe, then package its output.
/// Build a Void source template and install its complete runtime package tree.
Build { recipe: PathBuf, output: PathBuf },
/// Package an existing software tree without running its build command.
Pack { recipe: PathBuf, output: PathBuf },
/// Check a built software descriptor, archive digest and extracted contents.
/// Check installed package metadata and every program/dependency file.
Inspect { directory: PathBuf },
}
fn run() -> Result<()> {
@@ -76,16 +76,18 @@ fn run() -> Result<()> {
}
Action::Software { command } => serde_json::to_value(match command {
Software::Build { recipe, output } => {
fds_workstation::software::build(&recipe, &output, true)?
}
Software::Pack { recipe, output } => {
fds_workstation::software::build(&recipe, &output, false)?
fds_workstation::software::build(&recipe, &output, &cli.build)?
}
Software::Inspect { directory } => fds_workstation::software::load(&directory)?,
}),
Action::Create { recipe, output } => serde_json::to_value(
fds_workstation::cartridge::create(&recipe, &output, cli.image_tool_runner.as_deref())?,
),
Action::Create { recipe, output } => {
serde_json::to_value(fds_workstation::cartridge::create(
&recipe,
&output,
cli.image_tool_runner.as_deref(),
&cli.build,
)?)
}
Action::Inspect { image } => serde_json::to_value(fds_workstation::cartridge::inspect(
&image,
cli.image_tool_runner.as_deref(),
+211 -84
View File
@@ -1,22 +1,34 @@
use crate::{Work, parent, workstation};
//! Void source packages are built and installed on the workstation only.
use crate::{Work, image_tool, parent, success, workstation};
use fds_common::{Error, Result, manifest::identifier, read_text};
use fds_software::{Software, archive};
use fds_software::{Software, tree};
use serde::Deserialize;
use std::{
collections::BTreeMap,
fs::{self, File, OpenOptions},
os::unix::fs::{OpenOptionsExt, PermissionsExt},
fs::{self, File},
os::unix::fs::PermissionsExt,
path::{Path, PathBuf},
process::{Command, Stdio},
};
#[derive(clap::Args, Debug, Clone)]
pub struct BuildOptions {
/// Prepared Void source checkout used by xbps-src (not the workstation OS).
#[arg(long, global = true, default_value = "vendor/void-packages")]
pub void_packages: PathBuf,
/// Optional rootless XBPS wrapper, for example this checkout's tools/in-void.
#[arg(long, global = true)]
pub xbps_tool_runner: Option<PathBuf>,
/// Native XBPS executables for xbps-src, when they are not already in PATH.
#[arg(long, global = true)]
pub xbps_bin: Option<PathBuf>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Build {
directory: PathBuf,
command: Vec<String>,
#[serde(default)]
environment: BTreeMap<String, String>,
struct Source {
package: String,
/// Omit to build an existing source package from the selected Void checkout.
template: Option<PathBuf>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
@@ -25,97 +37,215 @@ struct Recipe {
id: String,
name: String,
version: String,
architecture: String,
root: PathBuf,
commands: BTreeMap<String, String>,
build: Option<Build>,
source: Source,
}
pub fn build(recipe: &Path, output: &Path, compile: bool) -> Result<Software> {
fn xbps(options: &BuildOptions, program: &str) -> Command {
let mut command = image_tool(options.xbps_tool_runner.as_deref(), "env");
command.args(["XBPS_ARCH=aarch64", "XBPS_TARGET_ARCH=aarch64", program]);
if let Some(directory) = &options.xbps_bin {
let mut paths = vec![directory.clone()];
paths.extend(std::env::split_paths(
&std::env::var_os("PATH").unwrap_or_default(),
));
if let Ok(path) = std::env::join_paths(paths) {
command.env("PATH", path);
}
}
command
}
pub fn build(recipe: &Path, output: &Path, options: &BuildOptions) -> Result<Software> {
workstation()?;
if unsafe { libc::geteuid() } == 0 {
return Err(Error(
"Run software builds as an ordinary workstation user".into(),
"Build Void software as an ordinary workstation user".into(),
));
}
if output.try_exists()? {
return Err(Error("Software output already exists".into()));
}
let input: Recipe = toml::from_str(&read_text(recipe, 65536)?)
.map_err(|e| Error(format!("Invalid build recipe: {e}")))?;
if input.format != 1 || !identifier(&input.id) {
.map_err(|e| Error(format!("Invalid Void software recipe: {e}")))?;
if input.format != 2
|| !identifier(&input.id)
|| !fds_software::xbps_identifier(&input.source.package)
{
return Err(Error(
"Build recipe requires format 1 and a valid software id".into(),
"Software recipe requires format 2, a software id and a Void source package name"
.into(),
));
}
let base = parent(recipe)?;
if compile {
let build = input.build.ok_or_else(|| {
Error("Build recipe lacks [build]; use software pack for an existing tree".into())
})?;
let program = build
.command
.first()
.ok_or_else(|| Error("Build command is empty".into()))?;
let directory = base.join(build.directory).canonicalize()?;
crate::success(
Command::new(program)
.args(&build.command[1..])
.current_dir(directory)
.envs(build.environment)
.env("FDS_TARGET_ARCH", &input.architecture)
.stdin(Stdio::null()),
let checkout = options.void_packages.canonicalize()?;
if !checkout.join("xbps-src").is_file() {
return Err(Error(
"--void-packages must name a prepared Void source checkout".into(),
));
}
let destination = checkout.join("srcpkgs").join(&input.source.package);
if let Some(template) = input.source.template {
let source = parent(recipe)?.join(template).canonicalize()?;
if !source.join("template").is_file() {
return Err(Error(
"Source package directory must contain a Void template".into(),
));
}
if source != destination {
let tracked = Command::new("git")
.arg("-C")
.arg(&checkout)
.args(["ls-files", "--"])
.arg(format!("srcpkgs/{}", input.source.package))
.output()?;
if !tracked.status.success() || !tracked.stdout.is_empty() {
return Err(Error(
"Custom source packages cannot replace tracked upstream Void files".into(),
));
}
if destination.exists() {
if !Command::new("diff")
.args(["-qr", "--"])
.arg(&source)
.arg(&destination)
.stdout(Stdio::null())
.status()?
.success()
{
return Err(Error(format!(
"Stale generated source overlay {}; preserve local edits and remove that copy before rebuilding",
destination.display()
)));
}
} else {
tree::copy(&source, &destination)?;
}
}
}
if !destination.join("template").is_file() {
return Err(Error(format!(
"Void source package {} has no template",
input.source.package
)));
}
// Source templates are trusted build code. Use normal xbps-src dependency
// resolution and cross compilation, including its workstation build hooks.
let mut source_build = Command::new(checkout.join("xbps-src"));
source_build
.args(["-f", "-a", "aarch64", "pkg", &input.source.package])
.current_dir(&checkout)
.env("XBPS_ARCH", std::env::consts::ARCH)
.stdout(Stdio::from(std::io::stderr()));
if let Some(directory) = &options.xbps_bin {
let mut paths = vec![directory.canonicalize()?];
paths.extend(std::env::split_paths(
&std::env::var_os("PATH").unwrap_or_default(),
));
source_build.env(
"PATH",
std::env::join_paths(paths).map_err(|e| Error(e.to_string()))?,
);
}
success(&mut source_build)?;
let output_parent = parent(output)?;
let work = Work::new(&output_parent)?;
let staged = work.0.join("software");
let root = staged.join("root");
let config = work.0.join("config");
let cache = work.0.join("cache");
fs::create_dir_all(root.join("var/db/xbps/keys"))?;
fs::create_dir(&config)?;
fs::create_dir(&cache)?;
for entry in fs::read_dir(checkout.join("common/repo-keys"))? {
let entry = entry?;
if entry.path().extension().is_some_and(|e| e == "plist") {
fs::copy(
entry.path(),
root.join("var/db/xbps/keys").join(entry.file_name()),
)?;
}
}
let mut install = xbps(options, "xbps-install");
install
.args(["-SyU", "--reproducible", "-i", "-C"])
.arg(&config)
.arg("-r")
.arg(&root)
.arg("-c")
.arg(&cache)
.args(["-R", "https://repo-default.voidlinux.org/current/aarch64"])
.arg("-R")
.arg(checkout.join("hostdir/binpkgs"))
.arg(&input.source.package);
// A supplied runner provides its own rootless XBPS environment. Otherwise
// root exists only inside a user namespace with this staging area writable.
if options.xbps_tool_runner.is_some() {
success(install.stdout(Stdio::from(std::io::stderr())))?;
} else {
success(
Command::new("bwrap")
.args([
"--unshare-user",
"--uid",
"0",
"--gid",
"0",
"--ro-bind",
"/",
"/",
"--dev",
"/dev",
"--proc",
"/proc",
"--bind",
])
.arg(&work.0)
.arg(&work.0)
.arg(install.get_program())
.args(install.get_args())
.envs(
install
.get_envs()
.filter_map(|(key, value)| value.map(|value| (key, value))),
)
.stdout(Stdio::from(std::io::stderr())),
)?;
}
let root = base.join(input.root).canonicalize()?;
let output_parent = parent(output)?;
if output_parent.starts_with(&root) {
return Err(Error(
"Software output must be outside its source tree".into(),
));
let list = xbps(options, "xbps-query")
.arg("-r")
.arg(&root)
.arg("-l")
.output()?;
if !list.status.success() {
return Err(Error("Cannot read installed XBPS package inventory".into()));
}
let work = Work::new(&output_parent)?;
let bundle = work.0.join("bundle");
fs::create_dir(&bundle)?;
let archive_path = bundle.join(format!("{}.tar.xz", input.id));
let encoded = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o644)
.open(&archive_path)?;
let mut child = Command::new("xz")
.args(["--compress", "--stdout", "--threads=1", "-6"])
.stdin(Stdio::piped())
.stdout(Stdio::from(encoded))
.stderr(Stdio::inherit())
.spawn()?;
let stats = archive::write_tar(&root, child.stdin.take().unwrap(), &input.architecture);
if stats.is_err() {
let _ = child.kill();
}
let status = child.wait()?;
let stats = stats?;
if !status.success() {
return Err(Error("XZ compression failed".into()));
}
let file = archive::open(&archive_path)?;
let mut packages: Vec<_> = String::from_utf8_lossy(&list.stdout)
.lines()
.filter_map(|line| line.split_whitespace().nth(1).map(str::to_owned))
.collect();
packages.sort();
packages.dedup();
tree::relocate(&root)?;
let inventory = tree::inspect(&root, "aarch64")?;
let software = Software {
id: input.id,
name: input.name,
version: input.version,
architecture: input.architecture,
architecture: "aarch64".into(),
partition: 2,
archive_bytes: file.metadata()?.len(),
unpacked_bytes: stats.bytes,
entries: stats.entries,
sha256: archive::digest(&file)?,
installed: true,
packages,
archive_bytes: 0,
unpacked_bytes: inventory.bytes,
entries: inventory.entries,
sha256: inventory.sha256,
commands: input.commands,
};
archive::verify(&file, &software, None)?;
let descriptor = toml::to_string(&software).map_err(|e| Error(e.to_string()))?;
fs::write(bundle.join("software.toml"), descriptor)?;
fs::set_permissions(&bundle, fs::Permissions::from_mode(0o755))?;
// renameat2(NO_REPLACE) publishes the complete directory without overwriting.
let from = std::ffi::CString::new(bundle.as_os_str().as_encoded_bytes())
tree::verify(&root, &software)?;
fs::write(
staged.join("software.toml"),
toml::to_string(&software).map_err(|e| Error(e.to_string()))?,
)?;
fs::set_permissions(&staged, fs::Permissions::from_mode(0o755))?;
let from = std::ffi::CString::new(staged.as_os_str().as_encoded_bytes())
.map_err(|e| Error(e.to_string()))?;
let to = std::ffi::CString::new(output.as_os_str().as_encoded_bytes())
.map_err(|e| Error(e.to_string()))?;
@@ -136,17 +266,14 @@ pub fn build(recipe: &Path, output: &Path, compile: bool) -> Result<Software> {
}
pub fn load(directory: &Path) -> Result<Software> {
let path = directory.join("software.toml");
let metadata = fs::symlink_metadata(&path)?;
if !metadata.is_file() {
if !path.symlink_metadata()?.is_file() {
return Err(Error("Software descriptor must be a regular file".into()));
}
let software: Software = toml::from_str(&read_text(&path, 65536)?)
.map_err(|e| Error(format!("Invalid software descriptor: {e}")))?;
software.validate()?;
archive::verify(
&archive::open(&directory.join(format!("{}.tar.xz", software.id)))?,
&software,
None,
)?;
if !software.installed {
return Err(Error("New cartridges require installed Void packages; rebuild this legacy bundle from its source template".into()));
}
tree::verify(&directory.join("root"), &software)?;
Ok(software)
}