FDS/OS 1.0
This commit is contained in:
@@ -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(¤t) {
|
||||
Ok(()) => fs::set_permissions(¤t, fs::Permissions::from_mode(0o755))?,
|
||||
Err(error)
|
||||
if error.kind() == io::ErrorKind::AlreadyExists
|
||||
&& fs::symlink_metadata(¤t)?.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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Verified software archives shared by workstation and guest tools.
|
||||
pub use fds_common::software::*;
|
||||
pub mod archive;
|
||||
Reference in New Issue
Block a user