FDS/OS 1.0

This commit is contained in:
2026-09-21 22:29:23 +08:00
commit 99bc3d15c5
430 changed files with 34876 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "fds-software"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "Bounded software catalogues and verified xz tar bundles"
[dependencies]
fds-common = { path = "../fds-common" }
serde = { version = "1", features = ["derive"] }
toml = "0.8"
sha2 = "=0.10.9"
tar = { version = "=0.4.46", default-features = false }
libc = "0.2"
+499
View File
@@ -0,0 +1,499 @@
//! Streaming xz/ustar bundles with explicit file types, paths and resource limits.
use crate::{MAX_ARCHIVE, MAX_ENTRIES, MAX_UNPACKED, Software, relative};
use fds_common::{Error, Result};
use sha2::{Digest, Sha256};
use std::{
collections::{BTreeMap, BTreeSet},
fs::{self, File, OpenOptions},
io::{self, Read, Seek, SeekFrom, Write},
os::unix::fs::{OpenOptionsExt, PermissionsExt},
path::Path,
process::{Child, Command, Stdio},
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Stats {
pub bytes: u64,
pub entries: u32,
}
pub fn digest(file: &File) -> Result<String> {
let mut input = file.try_clone()?;
input.seek(SeekFrom::Start(0))?;
let mut hash = Sha256::new();
let mut buffer = [0; 64 * 1024];
loop {
let n = input.read(&mut buffer)?;
if n == 0 {
break;
}
hash.update(&buffer[..n]);
}
Ok(hash.finalize().iter().map(|b| format!("{b:02x}")).collect())
}
pub fn open(path: &Path) -> Result<File> {
let file = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC)
.open(path)?;
if !file.metadata()?.is_file() || !(1..=MAX_ARCHIVE).contains(&file.metadata()?.len()) {
return Err(Error(
"Software archive must be a nonempty regular file of at most 512 MiB".into(),
));
}
Ok(file)
}
struct Process(Child);
impl Drop for Process {
fn drop(&mut self) {
if self.0.try_wait().ok().flatten().is_none() {
let _ = self.0.kill();
}
let _ = self.0.wait();
}
}
struct Budget<R> {
inner: R,
remaining: u64,
}
impl<R: Read> Read for Budget<R> {
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
if buffer.is_empty() {
return Ok(0);
}
if self.remaining == 0 {
let mut probe = [0];
if self.inner.read(&mut probe)? == 0 {
return Ok(0);
}
return Err(io::Error::other(
"Software archive exceeds its decompression limit",
));
}
let limit = (buffer.len() as u64).min(self.remaining) as usize;
let n = self.inner.read(&mut buffer[..limit])?;
self.remaining -= n as u64;
Ok(n)
}
}
fn elf(prefix: &[u8], architecture: &str) -> Result<()> {
if prefix.starts_with(b"\x7fELF")
&& (architecture != "aarch64"
|| prefix.len() < 20
|| prefix[4] != 2
|| prefix[5] != 1
|| prefix[18..20] != [183, 0])
{
return Err(Error(
"Software contains an ELF executable/library for the wrong architecture".into(),
));
}
Ok(())
}
/// Decode only directories and regular files. Ownership, timestamps, links,
/// sparse files, device nodes and extension records never control extraction.
/// DEST must be a newly created private directory, inaccessible to other users.
pub fn read_tar(
input: impl Read,
software: &Software,
destination: Option<&Path>,
) -> Result<Stats> {
software.validate()?;
if let Some(root) = destination {
let metadata = root.symlink_metadata()?;
if !metadata.is_dir()
|| metadata.permissions().mode() & 0o077 != 0
|| fs::read_dir(root)?.next().is_some()
{
return Err(Error(
"Software extraction requires an empty private directory".into(),
));
}
}
let budget = software
.unpacked_bytes
.checked_add(u64::from(software.entries) * 1024 + 64 * 1024)
.ok_or_else(|| Error("Archive budget overflow".into()))?;
let mut archive = tar::Archive::new(Budget {
inner: input,
remaining: budget,
});
let mut seen: BTreeMap<String, (bool, bool)> = BTreeMap::new();
let mut stats = Stats {
bytes: 0,
entries: 0,
};
for entry in archive.entries()?.raw(true) {
let mut entry = entry?;
let kind = entry.header().entry_type();
if !(kind.is_file() || kind.is_dir()) {
return Err(Error("Software tarballs permit only regular files and directories (no links or extensions)".into()));
}
let name = String::from_utf8(entry.path_bytes().into_owned())
.map_err(|_| Error("Archive path is not UTF-8".into()))?;
let name = if kind.is_dir() {
name.strip_suffix('/').unwrap_or(&name)
} else {
&name
};
if !relative(name) || seen.contains_key(name) || entry.header().mode()? & 0o7000 != 0 {
return Err(Error(
"Unsafe, duplicate or privileged software archive entry".into(),
));
}
for parent in Path::new(name)
.ancestors()
.skip(1)
.filter(|p| !p.as_os_str().is_empty())
{
if seen
.get(parent.to_str().unwrap())
.is_some_and(|(directory, _)| !directory)
{
return Err(Error("Software entry has a non-directory parent".into()));
}
}
if kind.is_file() {
let prefix = format!("{name}/");
if seen
.range(prefix.clone()..)
.next()
.is_some_and(|(path, _)| path.starts_with(&prefix))
{
return Err(Error(
"Software file replaces a previously implied directory".into(),
));
}
}
let size = entry.size();
if kind.is_dir() && size != 0 {
return Err(Error("Directory entry contains data".into()));
}
stats.entries += 1;
stats.bytes = stats
.bytes
.checked_add(size)
.ok_or_else(|| Error("Software size overflow".into()))?;
if stats.entries > software.entries
|| stats.entries > MAX_ENTRIES
|| stats.bytes > software.unpacked_bytes
|| stats.bytes > MAX_UNPACKED
{
return Err(Error("Software exceeds declared extraction limits".into()));
}
let executable = entry.header().mode()? & 0o111 != 0;
seen.insert(name.to_owned(), (kind.is_dir(), executable));
let output = destination.map(|root| root.join(name));
if kind.is_dir() {
if let Some(path) = output {
directories(destination.unwrap(), &path)?;
}
} else {
let mut prefix = vec![0; size.min(64) as usize];
entry.read_exact(&mut prefix)?;
elf(&prefix, &software.architecture)?;
if let Some(path) = output {
directories(destination.unwrap(), path.parent().unwrap())?;
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(if executable { 0o755 } else { 0o644 })
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
.open(path)?;
file.write_all(&prefix)?;
io::copy(&mut entry, &mut file)?;
file.set_permissions(fs::Permissions::from_mode(if executable {
0o755
} else {
0o644
}))?;
} else {
io::copy(&mut entry, &mut io::sink())?;
}
}
}
// Reject a second tar archive or nonzero content following the tar end marker.
let mut input = archive.into_inner();
let mut buffer = [0; 4096];
loop {
let n = input.read(&mut buffer)?;
if n == 0 {
break;
}
if buffer[..n].iter().any(|b| *b != 0) {
return Err(Error("Unexpected trailing archive content".into()));
}
}
if stats.bytes != software.unpacked_bytes || stats.entries != software.entries {
return Err(Error(
"Software contents do not match declared sizes/counts".into(),
));
}
for command in software.commands.values() {
if seen.get(command) != Some(&(false, true)) {
return Err(Error(format!(
"Declared command is not an executable regular file: {command}"
)));
}
}
Ok(stats)
}
fn directories(root: &Path, path: &Path) -> Result<()> {
let mut current = root.to_path_buf();
for component in path
.strip_prefix(root)
.map_err(|_| Error("Extraction path escaped its root".into()))?
.components()
{
current.push(component);
match fs::create_dir(&current) {
Ok(()) => fs::set_permissions(&current, fs::Permissions::from_mode(0o755))?,
Err(error)
if error.kind() == io::ErrorKind::AlreadyExists
&& fs::symlink_metadata(&current)?.is_dir() =>
{
()
}
Err(error) => return Err(error.into()),
}
}
Ok(())
}
pub fn verify(file: &File, software: &Software, destination: Option<&Path>) -> Result<Stats> {
software.validate()?;
if !file.metadata()?.is_file()
|| file.metadata()?.len() != software.archive_bytes
|| digest(file)? != software.sha256
{
return Err(Error("Software archive length or SHA-256 mismatch".into()));
}
let mut input = file.try_clone()?;
input.seek(SeekFrom::Start(0))?;
let mut child = Process(
Command::new("xz")
.args(["--decompress", "--stdout", "--memlimit-decompress=256MiB"])
.stdin(Stdio::from(input))
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()?,
);
let result = read_tar(child.0.stdout.take().unwrap(), software, destination)?;
if !child.0.wait()?.success() {
return Err(Error("XZ decompression failed".into()));
}
if file.metadata()?.len() != software.archive_bytes || digest(file)? != software.sha256 {
return Err(Error("Software archive changed during verification".into()));
}
Ok(result)
}
/// Workstation construction emits deterministic USTAR. In-tree file symlinks
/// are copied as regular files; directory links and escaping links are rejected.
pub fn write_tar(root: &Path, output: impl Write, architecture: &str) -> Result<Stats> {
if !matches!(architecture, "aarch64" | "any") {
return Err(Error("Expected architecture aarch64 or any".into()));
}
let root = root.canonicalize()?;
let mut paths = BTreeSet::new();
fn walk(root: &Path, dir: &Path, paths: &mut BTreeSet<String>) -> Result<()> {
for item in fs::read_dir(dir)? {
let item = item?;
let path = item.path();
let name = path
.strip_prefix(root)
.unwrap()
.to_str()
.ok_or_else(|| Error("Software paths must be UTF-8".into()))?
.to_owned();
if !relative(&name) || name.len() > 255 {
return Err(Error(
"Software path is unsafe or exceeds USTAR's 255-byte limit".into(),
));
}
let kind = item.file_type()?;
if kind.is_dir() {
walk(root, &path, paths)?;
} else if !(kind.is_file()
|| kind.is_symlink() && path.canonicalize()?.starts_with(root) && path.is_file())
{
return Err(Error(
"Software tree contains an unsupported node or an escaping/directory symlink"
.into(),
));
}
paths.insert(name);
if paths.len() > MAX_ENTRIES as usize {
return Err(Error("Too many software files".into()));
}
}
Ok(())
}
walk(&root, &root, &mut paths)?;
let mut builder = tar::Builder::new(output);
let mut stats = Stats {
bytes: 0,
entries: 0,
};
for name in paths {
let path = root.join(&name);
let metadata = path.metadata()?;
let size = if metadata.is_dir() { 0 } else { metadata.len() };
stats.bytes = stats
.bytes
.checked_add(size)
.ok_or_else(|| Error("Software too large".into()))?;
if stats.bytes > MAX_UNPACKED {
return Err(Error("Software tree exceeds 1 GiB".into()));
}
let mut header = tar::Header::new_ustar();
header.set_path(&name)?;
header.set_uid(0);
header.set_gid(0);
header.set_mtime(0);
header.set_size(size);
header.set_entry_type(if metadata.is_dir() {
tar::EntryType::Directory
} else {
tar::EntryType::Regular
});
header.set_mode(
if metadata.is_dir() || metadata.permissions().mode() & 0o111 != 0 {
0o755
} else {
0o644
},
);
header.set_cksum();
if metadata.is_dir() {
builder.append(&header, io::empty())?;
} else {
let mut input = File::open(&path)?;
let mut prefix = vec![0; size.min(64) as usize];
input.read_exact(&mut prefix)?;
elf(&prefix, architecture)?;
input.seek(SeekFrom::Start(0))?;
builder.append(&header, &mut input)?;
}
stats.entries += 1;
}
builder.finish()?;
Ok(stats)
}
#[cfg(test)]
mod tests {
use super::*;
fn metadata() -> Software {
Software {
id: "test.tool".into(),
name: "Tool".into(),
version: "1".into(),
architecture: "any".into(),
partition: 2,
archive_bytes: 100,
unpacked_bytes: 3,
entries: 1,
sha256: "a".repeat(64),
commands: [("tool".into(), "bin/tool".into())].into(),
}
}
fn tar(kind: tar::EntryType, path: &str, mode: u32) -> Vec<u8> {
let mut data = Vec::new();
let mut b = tar::Builder::new(&mut data);
let mut h = tar::Header::new_ustar();
h.set_path(path).unwrap();
h.set_mode(mode);
h.set_uid(0);
h.set_gid(0);
h.set_mtime(0);
h.set_size(if kind.is_file() { 3 } else { 0 });
h.set_entry_type(kind);
h.set_cksum();
b.append(&h, if kind.is_file() { &b"abc"[..] } else { &[][..] })
.unwrap();
b.finish().unwrap();
drop(b);
data
}
#[test]
fn file_cannot_replace_an_implicit_parent_directory() {
let mut data = tar(tar::EntryType::Regular, "bin/tool", 0o755);
data.truncate(1024);
data.extend(tar(tar::EntryType::Regular, "bin", 0o644));
let mut software = metadata();
software.entries = 2;
software.unpacked_bytes = 6;
assert!(read_tar(&data[..], &software, None).is_err());
let root = std::env::temp_dir().join(format!("fds-archive-mode-{}", std::process::id()));
fs::create_dir(&root).unwrap();
fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap();
let good = tar(tar::EntryType::Regular, "bin/tool", 0o755);
read_tar(&good[..], &metadata(), Some(&root)).unwrap();
assert_eq!(
root.join("bin").metadata().unwrap().permissions().mode() & 0o777,
0o755
);
assert_eq!(
root.join("bin/tool")
.metadata()
.unwrap()
.permissions()
.mode()
& 0o777,
0o755
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn file_types_modes_counts_trailing_data_and_commands_are_checked() {
let good = tar(tar::EntryType::Regular, "bin/tool", 0o755);
assert_eq!(
read_tar(&good[..], &metadata(), None).unwrap(),
Stats {
bytes: 3,
entries: 1
}
);
for kind in [
tar::EntryType::Symlink,
tar::EntryType::Link,
tar::EntryType::Fifo,
tar::EntryType::Char,
tar::EntryType::GNUSparse,
] {
assert!(read_tar(&tar(kind, "bin/tool", 0o755)[..], &metadata(), None).is_err());
}
assert!(
read_tar(
&tar(tar::EntryType::Regular, "bin/tool", 0o4755)[..],
&metadata(),
None
)
.is_err()
);
assert!(
read_tar(
&tar(tar::EntryType::Regular, "bin/tool", 0o644)[..],
&metadata(),
None
)
.is_err()
);
let mut short = metadata();
short.unpacked_bytes = 2;
assert!(read_tar(&good[..], &short, None).is_err());
let mut trailing = good.clone();
trailing.extend_from_slice(b"unexpected");
assert!(read_tar(&trailing[..], &metadata(), None).is_err());
let mut unsafe_name = good.clone();
unsafe_name[..100].fill(0);
unsafe_name[..9].copy_from_slice(b"../escape");
unsafe_name[148..156].fill(b' ');
let checksum: u32 = unsafe_name[..512].iter().map(|b| u32::from(*b)).sum();
unsafe_name[148..156].copy_from_slice(format!("{checksum:06o}\0 ").as_bytes());
assert!(read_tar(&unsafe_name[..], &metadata(), None).is_err());
}
}
+3
View File
@@ -0,0 +1,3 @@
//! Verified software archives shared by workstation and guest tools.
pub use fds_common::software::*;
pub mod archive;