Files
TerminalBasic/crates/tb-export/src/lib.rs

515 lines
17 KiB
Rust

//! Geprüfte Runtime-Vorlagen, Nutzlastcontainer und geschützte Veröffentlichung.
use anyhow::{bail, ensure, Context, Result};
use std::{
fs,
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
};
use tb_vm::bytecode::{CompiledModule, TBC_VERSION};
pub const RUNTIME_VERSION: u32 = 1;
const CONTAINER_VERSION: u32 = 1;
const MAGIC: &[u8; 8] = b"TBPCODE!";
const FOOTER: usize = 48;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u32)]
pub enum Target {
WindowsAmd64 = 1,
MacosArm64 = 2,
LinuxAmd64 = 3,
LinuxArm64 = 4,
}
impl Target {
pub const ALL: [Self; 4] = [
Self::WindowsAmd64,
Self::MacosArm64,
Self::LinuxAmd64,
Self::LinuxArm64,
];
pub fn triple(self) -> &'static str {
match self {
Self::WindowsAmd64 => "x86_64-pc-windows-msvc",
Self::MacosArm64 => "aarch64-apple-darwin",
Self::LinuxAmd64 => "x86_64-unknown-linux-gnu",
Self::LinuxArm64 => "aarch64-unknown-linux-gnu",
}
}
pub fn parse(s: &str) -> Result<Self> {
Self::ALL
.into_iter()
.find(|t| t.triple() == s)
.with_context(|| format!("Nicht unterstütztes Target: {s}"))
}
pub fn host() -> Result<Self> {
match (std::env::consts::OS, std::env::consts::ARCH) {
("windows", "x86_64") => Ok(Self::WindowsAmd64),
("macos", "aarch64") => Ok(Self::MacosArm64),
("linux", "x86_64") => Ok(Self::LinuxAmd64),
("linux", "aarch64") => Ok(Self::LinuxArm64),
other => bail!("Kein unterstütztes Host-Target: {other:?}"),
}
}
pub fn runtime_name(self) -> &'static str {
if self == Self::WindowsAmd64 {
"tbrt.exe"
} else {
"tbrt"
}
}
}
fn part(b: &[u8], at: usize, len: usize) -> Result<&[u8]> {
b.get(at..at.checked_add(len).context("Längenüberlauf")?)
.context("Abgeschnittenes natives Format/Nutzlast")
}
fn u16_at(b: &[u8], at: usize) -> Result<u16> {
Ok(u16::from_le_bytes(part(b, at, 2)?.try_into()?))
}
fn u32_at(b: &[u8], at: usize) -> Result<u32> {
Ok(u32::from_le_bytes(part(b, at, 4)?.try_into()?))
}
fn u64_at(b: &[u8], at: usize) -> Result<u64> {
Ok(u64::from_le_bytes(part(b, at, 8)?.try_into()?))
}
fn size_at(b: &[u8], at: usize) -> Result<usize> {
usize::try_from(u64_at(b, at)?).context("Nutzlastlänge nicht darstellbar")
}
fn put64(b: &mut [u8], at: usize, n: usize) {
b[at..at + 8].copy_from_slice(&(n as u64).to_le_bytes());
}
/// FNV-1a-64 gegen Übertragungs-/Dateibeschädigung; keine Herkunftsauthentisierung.
pub fn checksum(b: &[u8]) -> u64 {
b.iter().fold(0xcbf29ce484222325, |h, v| {
(h ^ u64::from(*v)).wrapping_mul(0x100000001b3)
})
}
fn macho_commands(b: &[u8]) -> Result<Vec<(u32, usize)>> {
let end = 32usize
.checked_add(u32_at(b, 20)? as usize)
.context("Mach-O-Headerlänge")?;
part(b, 0, end)?;
let mut at = 32;
let mut commands = Vec::new();
for _ in 0..u32_at(b, 16)? {
ensure!(at + 8 <= end, "Ungültige Mach-O-Ladekommandos");
let cmd = u32_at(b, at)?;
let len = u32_at(b, at + 4)? as usize;
ensure!(
len >= 8 && len.is_multiple_of(8) && len <= end - at,
"Ungültige Mach-O-Kommandolänge"
);
commands.push((cmd, at));
at += len;
}
ensure!(at == end, "Mach-O-Kommandotabelle unvollständig");
Ok(commands)
}
/// Format-/Architekturprüfung; synthetische Header sind kein Zielausführungsnachweis.
pub fn native_target(b: &[u8]) -> Result<Target> {
part(b, 0, 64)?;
if b.starts_with(b"MZ") {
let pe = u32_at(b, 0x3c)? as usize;
ensure!(part(b, pe, 4)? == b"PE\0\0", "Ungültiges PE-Format");
ensure!(
u16_at(b, pe + 4)? == 0x8664,
"PE-Architektur muss amd64 sein"
);
ensure!(
u16_at(b, pe + 24)? == 0x20b && u16_at(b, pe + 92)? == 3,
"PE muss PE32+ Terminalanwendung sein"
);
ensure!(
u16_at(b, pe + 22)? & 0x2002 == 2,
"PE muss Executable statt DLL sein"
);
return Ok(Target::WindowsAmd64);
}
if b.starts_with(b"\x7fELF") {
ensure!(
matches!(b[7], 0 | 3),
"ELF-System-ABI muss System V/Linux sein"
);
ensure!(
part(b, 4, 3)? == [2, 1, 1],
"ELF muss 64-Bit Little Endian sein"
);
ensure!(matches!(u16_at(b, 16)?, 2 | 3), "ELF muss ausführbar sein");
part(b, 0, 64)?;
return match u16_at(b, 18)? {
62 => Ok(Target::LinuxAmd64),
183 => Ok(Target::LinuxArm64),
_ => bail!("Nicht unterstützte ELF-Architektur"),
};
}
if b.starts_with(&[0xcf, 0xfa, 0xed, 0xfe]) {
ensure!(
u32_at(b, 4)? == 0x0100000c,
"Mach-O-Architektur muss arm64 sein"
);
ensure!(u32_at(b, 12)? == 2, "Mach-O muss Executable sein");
macho_commands(b)?;
return Ok(Target::MacosArm64);
}
bail!("Unbekanntes natives Zielformat (PE/ELF/Mach-O erwartet)")
}
/// Mach-O-Signaturen folgen der Nutzlast. Ihr Ladekommando ist die stabile Grenze.
fn content_end(b: &[u8], target: Target) -> Result<usize> {
if target == Target::MacosArm64 {
let signatures: Vec<_> = macho_commands(b)?
.into_iter()
.filter(|(cmd, _)| *cmd == 0x1d)
.collect();
ensure!(signatures.len() <= 1, "Doppelte Mach-O-Signatur");
if let Some((_, at)) = signatures.first() {
ensure!(u32_at(b, at + 4)? == 16, "Ungültiges Signaturkommando");
let off = u32_at(b, at + 8)? as usize;
let len = u32_at(b, at + 12)? as usize;
part(b, off, len)?;
ensure!(off + len == b.len(), "Daten hinter Mach-O-Signatur");
return Ok(off);
}
}
Ok(b.len())
}
pub fn embedded(b: &[u8], expected: Target) -> Result<CompiledModule> {
ensure!(
native_target(b)? == expected,
"Executable-Target passt nicht zum Host"
);
let end = content_end(b, expected)?;
// codesign richtet die Signatur auf 16 Bytes aus; ausschließlich Nullpadding zulassen.
let footer = (0..=15)
.find_map(|padding| {
let at = end.checked_sub(FOOTER + padding)?;
(b.get(at..at + 8) == Some(MAGIC) && b[at + FOOTER..end].iter().all(|v| *v == 0))
.then_some(at)
})
.context("Fehlende oder beschädigte eingebettete Nutzlast (leere tbrt-Vorlage)")?;
ensure!(
u32_at(b, footer + 8)? == CONTAINER_VERSION,
"Inkompatible Container-Version"
);
ensure!(
u32_at(b, footer + 12)? == RUNTIME_VERSION,
"Inkompatible Runtime-Version"
);
ensure!(
u32_at(b, footer + 16)? == u32::from(TBC_VERSION),
"Inkompatible TBC-Version"
);
ensure!(
u32_at(b, footer + 20)? == expected as u32,
"Nutzlast-Target passt nicht zum Executable"
);
let offset = size_at(b, footer + 24)?;
let len = size_at(b, footer + 32)?;
ensure!(
offset >= 64 && offset <= footer && len == footer - offset,
"Ungültige Nutzlastgrenzen"
);
let payload = part(b, offset, len)?;
ensure!(
checksum(payload) == u64_at(b, footer + 40)?,
"Nutzlast-Prüfsumme stimmt nicht"
);
CompiledModule::from_tbc(payload)
.map_err(|e| anyhow::anyhow!("Eingebettetes Programm nicht ladbar: {e}"))
}
/// Im nativen Runner vorhanden; verhindert Verwechslung mit beliebigen Executables.
pub const fn runtime_marker(target: Target) -> [u8; 32] {
let mut marker = [0; 32];
let magic = *b"TBRT-RUNTIME-v1!";
let mut i = 0;
while i < 16 {
marker[i] = magic[i];
i += 1;
}
let values = [
CONTAINER_VERSION,
RUNTIME_VERSION,
TBC_VERSION as u32,
target as u32,
];
i = 0;
while i < 4 {
let bytes = values[i].to_le_bytes();
let mut j = 0;
while j < 4 {
marker[16 + i * 4 + j] = bytes[j];
j += 1;
}
i += 1;
}
marker
}
fn check_runner(b: &[u8], target: Target) -> Result<()> {
ensure!(
b.windows(32).any(|w| w == runtime_marker(target)),
"Keine kompatible tbrt-Runtime-Vorlage (Runtime-/Format-/Target-Markierung fehlt)"
);
Ok(())
}
fn manifest(b: &[u8], target: Target) -> String {
format!("TBRT-TEMPLATE 1\nruntime={RUNTIME_VERSION}\npackage={}\ntbc={TBC_VERSION}\ntarget={}\nchecksum={:016x}\n",env!("CARGO_PKG_VERSION"),target.triple(),checksum(b))
}
pub fn manifest_path(path: &Path) -> PathBuf {
let mut p = path.as_os_str().to_owned();
p.push(".meta");
PathBuf::from(p)
}
/// Für den Runtime-Builder; kein impliziter Build beim Export.
pub fn prepare_template(path: &Path, target: Target) -> Result<()> {
let b = fs::read(path)?;
check_runner(&b, target)?;
ensure!(
native_target(&b)? == target,
"Vorlagen-Zielformat/Architektur widerspricht {}",
target.triple()
);
ensure!(
embedded(&b, target).is_err(),
"Bereits eingebettetes Programm ist keine leere Vorlage"
);
publish(
&manifest_path(path),
false,
&[path.to_path_buf()],
&|| false,
|temp| {
fs::write(temp, manifest(&b, target))?;
Ok(())
},
)
}
fn template(path: &Path, target: Target) -> Result<Vec<u8>> {
let b = fs::read(path).with_context(|| format!("Runtime-Vorlage fehlt: {}", path.display()))?;
ensure!(
native_target(&b)? == target,
"Vorlagen-Zielformat/Architektur passt nicht zu {}",
target.triple()
);
let found = fs::read_to_string(manifest_path(path)).context("Vorlagenmetadaten fehlen")?;
check_runner(&b, target)?;
let want = manifest(&b, target);
for (index, (a, z)) in found.lines().zip(want.lines()).enumerate() {
ensure!(
a == z,
"Inkompatible Vorlagenmetadaten, Zeile {}: Soll {z}, Ist {a}",
index + 1
);
}
ensure!(
found == want,
"Unvollständige Vorlagenmetadaten oder Integritätsdaten"
);
Ok(b)
}
static NEXT: AtomicU64 = AtomicU64::new(0);
struct Temporary(PathBuf);
impl Drop for Temporary {
fn drop(&mut self) {
let _ = fs::remove_file(&self.0);
}
}
fn destination(path: &Path, protected: &[PathBuf]) -> Result<Option<Vec<u8>>> {
let key = tb_vm::project_io::identity(path)?;
for source in protected {
ensure!(
tb_vm::project_io::identity(source)? != key,
"Ausgabe würde Projektdaten/Vorlage überschreiben: {}",
path.display()
);
}
match fs::symlink_metadata(path) {
Ok(meta) => {
ensure!(
meta.is_file() && !meta.permissions().readonly(),
"Ziel ist keine beschreibbare reguläre Datei: {}",
path.display()
);
Ok(Some(fs::read(path)?))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
/// No-clobber per hard_link; Ersetzen nur nach expliziter Freigabe und unverändertem Ziel.
/// prepare darf finalisieren; cancellation wird vor Arbeit und Veröffentlichung geprüft.
pub fn publish(
path: &Path,
overwrite: bool,
protected: &[PathBuf],
cancelled: &dyn Fn() -> bool,
prepare: impl FnOnce(&Path) -> Result<()>,
) -> Result<()> {
ensure!(!cancelled(), "Export abgebrochen");
let initial = destination(path, protected)?;
ensure!(
initial.is_none() || overwrite,
"Ziel existiert; --force zum Ersetzen: {}",
path.display()
);
let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
let temporary = loop {
let p = parent.join(format!(
".tb-export-{}-{}.tmp",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
match fs::OpenOptions::new().write(true).create_new(true).open(&p) {
Ok(_) => break Temporary(p),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e.into()),
}
};
prepare(&temporary.0)?;
fs::File::open(&temporary.0)?.sync_all()?;
ensure!(!cancelled(), "Export abgebrochen");
ensure!(
destination(path, protected)? == initial,
"Ziel wurde während des Exports verändert"
);
if initial.is_some() {
fs::rename(&temporary.0, path)?;
} else {
fs::hard_link(&temporary.0, path)
.context("Ziel inzwischen belegt oder Veröffentlichung nicht möglich")?;
}
Ok(())
}
fn codesign(path: &Path, args: &[&str]) -> Result<()> {
ensure!(
cfg!(target_os = "macos"),
"macOS-Finalisierung benötigt macOS mit /usr/bin/codesign"
);
let result = std::process::Command::new("/usr/bin/codesign")
.args(args)
.arg(path)
.output()
.context("Finalisierung: /usr/bin/codesign fehlt oder ist nicht ausführbar")?;
ensure!(
result.status.success(),
"Finalisierung fehlgeschlagen: {}",
String::from_utf8_lossy(&result.stderr)
);
Ok(())
}
fn append_payload(b: &mut Vec<u8>, module: &CompiledModule, target: Target) -> Result<()> {
module.validate().map_err(|e| anyhow::anyhow!("{e}"))?;
if target == Target::WindowsAmd64 {
let pe = u32_at(b, 0x3c)? as usize;
// Eine bestehende Authenticode-Signatur wäre nach dem Einbetten ungültig.
if u32_at(b, pe + 132)? > 4 {
ensure!(
part(b, pe + 168, 8)?.iter().all(|v| *v == 0),
"Signierte PE-Vorlagen werden nicht unterstützt; unsigned tbrt benötigt"
);
}
// Für Benutzerprogramme optional; keine veraltete PE-Prüfsumme übernehmen.
b[pe + 88..pe + 92].fill(0);
}
let payload = module.to_tbc();
let offset = b.len();
b.extend_from_slice(&payload);
b.extend_from_slice(MAGIC);
for n in [
CONTAINER_VERSION,
RUNTIME_VERSION,
u32::from(TBC_VERSION),
target as u32,
] {
b.extend_from_slice(&n.to_le_bytes());
}
for n in [offset as u64, payload.len() as u64, checksum(&payload)] {
b.extend_from_slice(&n.to_le_bytes());
}
if target == Target::MacosArm64 {
let link = macho_commands(b)?
.into_iter()
.find(|(cmd, at)| {
*cmd == 0x19 && b.get(at + 8..at + 24) == Some(b"__LINKEDIT\0\0\0\0\0\0")
})
.context("Mach-O __LINKEDIT fehlt")?
.1;
ensure!(
u32_at(b, link + 4)? as usize >= 72,
"Mach-O-Segment zu kurz"
);
let start = size_at(b, link + 40)?;
ensure!(start <= offset, "Ungültige __LINKEDIT-Grenze");
let len = b.len() - start;
put64(b, link + 48, len);
put64(
b,
link + 32,
len.checked_add(16383).context("Mach-O-Länge")? & !16383,
);
}
Ok(())
}
pub struct Export<'a> {
pub module: &'a CompiledModule,
pub target: Target,
pub template: &'a Path,
pub output: &'a Path,
pub overwrite: bool,
pub protected: &'a [PathBuf],
}
pub fn export(request: Export<'_>, cancelled: &dyn Fn() -> bool) -> Result<()> {
let mut b = template(request.template, request.target)?;
let mut protected = request.protected.to_vec();
protected.extend([
request.template.to_path_buf(),
manifest_path(request.template),
]);
protected.extend(
request
.module
.sources
.iter()
.map(|s| PathBuf::from(&s.path)),
);
publish(
request.output,
request.overwrite,
&protected,
cancelled,
|temp| {
if request.target == Target::MacosArm64 {
fs::write(temp, &b)?;
codesign(temp, &["--remove-signature"])?;
b = fs::read(temp)?;
}
append_payload(&mut b, request.module, request.target)?;
fs::write(temp, &b)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(temp, fs::Permissions::from_mode(0o755))?;
}
if request.target == Target::MacosArm64 {
codesign(temp, &["--force", "--sign", "-", "--timestamp=none"])?;
codesign(temp, &["--verify", "--strict"])?;
}
embedded(&fs::read(temp)?, request.target)?;
Ok(())
},
)
}
#[cfg(test)]
mod tests;