diff --git a/Cargo.lock b/Cargo.lock index fe26eca..e947050 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -609,11 +609,23 @@ dependencies = [ "anyhow", "crossterm", "ratatui", + "signal-hook", + "tb-export", "tb-frontend", "tb-ide", + "tb-runner", "tb-runtime", "tb-ui", "tb-vm", + "windows-sys 0.59.0", +] + +[[package]] +name = "tb-export" +version = "0.1.0" +dependencies = [ + "anyhow", + "tb-vm", ] [[package]] @@ -639,6 +651,16 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "tb-runner" +version = "0.1.0" +dependencies = [ + "tb-export", + "tb-runtime", + "tb-ui", + "tb-vm", +] + [[package]] name = "tb-runtime" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a9f82fd..4604d52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,8 @@ members = [ "crates/tb-ui", "crates/tb-cli", "crates/tb-ide", + "crates/tb-runner", + "crates/tb-export", ] [workspace.package] @@ -38,6 +40,8 @@ log = "0.4" # Interne Crates tb-frontend = { path = "crates/tb-frontend" } tb-vm = { path = "crates/tb-vm" } +tb-runner = { path = "crates/tb-runner" } +tb-export = { path = "crates/tb-export" } tb-runtime = { path = "crates/tb-runtime" } tb-ui = { path = "crates/tb-ui" } diff --git a/README.md b/README.md index d794679..f68acbd 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ durchgängig UTF-8 statt Codepage 437 und keine Emulation von DOS-Hardware. | `tb-runtime` | Laufzeitbibliothek: Strings, Mathematik, Datei-E/A, Datum/Zeit, Fehlercodes | | `tb-ui` | Textbildschirm-Emulation und Forms-Engine auf Ratatui | | `tb-cli` | `tbc`: Standalone-Compiler — wandelt Quellen und Projekte in binäre Ergebnisse um | +| `tb-runner` | Gemeinsame CLI-Laufsteuerung und native Runtime-Vorlage `tbrt` | +| `tb-export` | Geprüfte Vorlagen, native Nutzlast und sichere Ausgabeveröffentlichung | | `tb-ide` | `tb`: die integrierte Entwicklungsumgebung (TUI) zum Erstellen von Programmen | ## Status @@ -78,3 +80,6 @@ Umsetzungsstands. ## Lizenz MIT — siehe [LICENSE](LICENSE). + +Native Programme lassen sich mit `tbc build --exe` aus vorgebauten Runtime-Vorlagen +erzeugen. Aufrufe, Zielvoraussetzungen und Abnahme: [Native Executables](docs/native-executables.md). diff --git a/crates/tb-cli/Cargo.toml b/crates/tb-cli/Cargo.toml index 35d239a..0448065 100644 --- a/crates/tb-cli/Cargo.toml +++ b/crates/tb-cli/Cargo.toml @@ -11,6 +11,8 @@ name = "tbc" path = "src/main.rs" [dependencies] +tb-runner.workspace = true +tb-export.workspace = true tb-frontend.workspace = true tb-vm.workspace = true tb-runtime.workspace = true @@ -21,3 +23,9 @@ anyhow.workspace = true tb-ide = { path = "../tb-ide" } crossterm.workspace = true ratatui.workspace = true + +[target.'cfg(unix)'.dependencies] +signal-hook.workspace = true + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = ["Win32_System_Console"] } diff --git a/crates/tb-cli/src/export_cancel.rs b/crates/tb-cli/src/export_cancel.rs new file mode 100644 index 0000000..494707b --- /dev/null +++ b/crates/tb-cli/src/export_cancel.rs @@ -0,0 +1,70 @@ +//! Prozessabbruch während eines CLI-Exports kontrolliert bis zur Bereinigung führen. +#[cfg(unix)] +pub struct ExportCancel { + flag: std::sync::Arc, + handlers: Vec, +} +#[cfg(unix)] +impl ExportCancel { + pub fn new() -> std::io::Result { + let mut result = Self { + flag: Default::default(), + handlers: Vec::new(), + }; + for signal in [signal_hook::consts::SIGINT, signal_hook::consts::SIGTERM] { + result + .handlers + .push(signal_hook::flag::register(signal, result.flag.clone())?); + } + Ok(result) + } + pub fn cancelled(&self) -> bool { + self.flag.load(std::sync::atomic::Ordering::Relaxed) + } +} +#[cfg(unix)] +impl Drop for ExportCancel { + fn drop(&mut self) { + for handler in &self.handlers { + signal_hook::low_level::unregister(*handler); + } + } +} + +#[cfg(windows)] +static CANCELLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +#[cfg(windows)] +unsafe extern "system" fn handle(signal: u32) -> i32 { + if signal <= 1 { + CANCELLED.store(true, std::sync::atomic::Ordering::Relaxed); + 1 + } else { + 0 + } +} +#[cfg(windows)] +pub struct ExportCancel; +#[cfg(windows)] +impl ExportCancel { + pub fn new() -> std::io::Result { + CANCELLED.store(false, std::sync::atomic::Ordering::Relaxed); + // Der CLI-Prozess hat genau einen Export. Win32 ruft den statischen Handler auf. + if unsafe { windows_sys::Win32::System::Console::SetConsoleCtrlHandler(Some(handle), 1) } + == 0 + { + return Err(std::io::Error::last_os_error()); + } + Ok(Self) + } + pub fn cancelled(&self) -> bool { + CANCELLED.load(std::sync::atomic::Ordering::Relaxed) + } +} +#[cfg(windows)] +impl Drop for ExportCancel { + fn drop(&mut self) { + unsafe { + windows_sys::Win32::System::Console::SetConsoleCtrlHandler(Some(handle), 0); + } + } +} diff --git a/crates/tb-cli/src/ide_acceptance.rs b/crates/tb-cli/src/ide_acceptance.rs index 0d0897a..104c9a7 100644 --- a/crates/tb-cli/src/ide_acceptance.rs +++ b/crates/tb-cli/src/ide_acceptance.rs @@ -13,7 +13,10 @@ use tb_ide::{ commands::Command, export::{ExportStatus, ProjectStamp}, }; +use tb_runtime::host::{Ereignis, Host}; use tb_runtime::{snapshot, value::Value}; +use tb_vm::interp::RunEvent; +use tb_vm::project_io::new_execution; struct Temp(PathBuf); impl Temp { fn new() -> Self { diff --git a/crates/tb-cli/src/main.rs b/crates/tb-cli/src/main.rs index 1ea5f58..6616d8a 100644 --- a/crates/tb-cli/src/main.rs +++ b/crates/tb-cli/src/main.rs @@ -12,10 +12,9 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; -use tb_runtime::host::{Ereignis, Host}; -use tb_ui::host::TerminalHost; -use tb_vm::interp::{RunEvent, Vm}; -use tb_vm::project_io::{module_name, new_execution, run_target, SourceLoader}; +use tb_vm::project_io::{module_name, SourceLoader}; + +mod export_cancel; fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); @@ -126,215 +125,147 @@ fn cmd_check(args: &[String]) -> ExitCode { } fn cmd_build(args: &[String]) -> ExitCode { - let (path, module) = match compile(args.first()) { - Ok(x) => x, - Err(code) => return code, - }; - let out = path.with_extension("tbc"); - match std::fs::write(&out, module.to_tbc()) { - Ok(()) => { - println!("{}", out.display()); + match build(args) { + Ok(path) => { + println!("{}", path.display()); ExitCode::SUCCESS } - Err(e) => { - eprintln!("{}: {e}", out.display()); + Err(error) => { + eprintln!("{error:#}"); ExitCode::from(1) } } } -fn cmd_run(args: &[String]) -> ExitCode { - // Ohne Terminal (Pipe, Skript, CI) läuft das Programm im PipeHost: - // Eingabe zeilenweise von stdin, Ausgabe am Ende als Snapshot. - let result = match TerminalHost::new() { - Ok(mut host) => { - let size = host.groesse().ok(); - let result = run_chain(args, &mut host, size); - drop(host); // Alternativschirm verlassen, bevor gedruckt wird - result - } - Err(_) => run_chain(args, &mut PipeHost::new(), None), - }; - let (ereignis, vm) = match result { - Ok(result) => result, - Err(code) => return code, - }; - print!("{}", tb_runtime::snapshot::text(&vm.rt.screen)); - // `LPRINT` sammelt im Druckerpuffer; am Programmende geht er in die - // Datei LPT1.TXT im aktuellen Verzeichnis (dokumentierte Abweichung — - // einen Druckerkanal gibt es plattformübergreifend nicht). - if !vm.rt.print.drucker.is_empty() { - if let Err(e) = std::fs::write("LPT1.TXT", &vm.rt.print.drucker) { - eprintln!("Druckerausgabe nicht schreibbar: {e}"); +fn build(args: &[String]) -> anyhow::Result { + use anyhow::{bail, ensure, Context}; + let mut source = None; + let mut exe = false; + let mut force = false; + let mut output = None; + let mut target = None; + let mut template = None; + let mut args = args.iter(); + while let Some(arg) = args.next() { + match arg.as_str() { + "--exe" => { + ensure!(!exe, "Doppeltes --exe"); + exe = true; + } + "--force" => { + ensure!(!force, "Doppeltes --force"); + force = true; + } + "-o" | "--output" | "--target" | "--template" => { + let value = args + .next() + .filter(|v| !v.starts_with('-')) + .with_context(|| format!("Wert fehlt für {arg}"))?; + let slot = match arg.as_str() { + "--target" => &mut target, + "--template" => &mut template, + _ => &mut output, + }; + ensure!( + slot.replace(value.clone()).is_none(), + "Doppelte Option {arg}" + ); + } + "--" => { + for value in args.by_ref() { + ensure!( + source.replace(value.clone()).is_none(), + "Mehrere Quelldateien angegeben" + ); + } + } + value if value.starts_with('-') => bail!("Unbekannte Build-Option: {value}"), + _ => { + ensure!( + source.replace(arg.clone()).is_none(), + "Mehrere Quelldateien angegeben" + ); + } } } - - match ereignis { - RunEvent::Ended => ExitCode::SUCCESS, - RunEvent::Stopped { line } => { - // STOP außerhalb der IDE: Meldung + Exit-Code ≠ 0 (D6). - eprintln!("{}:{line}: STOP in line {line}", vm.current_file()); - ExitCode::from(3) - } - RunEvent::Error { - code, - line, - message, - } => { - eprintln!( - "{}:{line}:{}: Runtime error {code}: {message} in line {line}", - vm.current_file(), - vm.current_source_pos().column - ); - ExitCode::from(2) - } - // Ohne Debugger-Flags treten diese Ereignisse nicht auf. - RunEvent::Interrupted { line } => { - eprintln!("Abgebrochen in Zeile {line}"); - ExitCode::from(3) - } - // Ohne Debugger-Flags treten diese Ereignisse nicht auf. - RunEvent::Breakpoint { .. } | RunEvent::Stepped { .. } => ExitCode::from(2), - RunEvent::Restart { .. } => unreachable!("RUN wird vom Runner aufgelöst"), + let source = source.context( + "Aufruf: tbc build [--exe --target --template -o --force]", + )?; + ensure!( + exe || (target.is_none() && template.is_none() && output.is_none() && !force), + "Native Ausgabeoptionen benötigen --exe" + ); + let selected = if exe { + Some(match target { + Some(s) => tb_export::Target::parse(&s)?, + None => tb_export::Target::host()?, + }) + } else { + None + }; + let (path, module) = + compile(Some(&source)).map_err(|_| anyhow::anyhow!("Build fehlgeschlagen"))?; + if let Some(target) = selected { + let out = output.map(PathBuf::from).unwrap_or_else(|| { + path.with_extension(if target == tb_export::Target::WindowsAmd64 { + "exe" + } else { + "" + }) + }); + let template = match template { + Some(p) => PathBuf::from(p), + None => std::env::current_exe()? + .parent() + .context("Compilerpfad ohne Verzeichnis")? + .join("runtimes") + .join(target.triple()) + .join(target.runtime_name()), + }; + let cancel = export_cancel::ExportCancel::new()?; + tb_export::export( + tb_export::Export { + module: &module, + target, + template: &template, + output: &out, + overwrite: force, + protected: &[path], + }, + &|| cancel.cancelled(), + )?; + Ok(out) + } else { + let out = path.with_extension("tbc"); + std::fs::write(&out, module.to_tbc())?; + Ok(out) } } -fn run_chain( - args: &[String], - host: &mut dyn Host, - size: Option<(usize, usize)>, -) -> Result<(RunEvent, Vm), ExitCode> { +fn cmd_run(args: &[String]) -> ExitCode { let Some(first) = args.first() else { eprintln!("Aufruf: tbc run "); - return Err(ExitCode::from(1)); + return ExitCode::from(1); }; - let mut current = PathBuf::from(first); - let mut start_line = None; - let command = args[1..].join(" "); - loop { - let current_arg = current.display().to_string(); - let (path, module) = compile(Some(¤t_arg))?; - let mut vm = new_execution(module, &command, size, start_line.take()).map_err(|error| { - eprintln!("Runtime error {}: {}", error.0, error); - ExitCode::from(2) - })?; - if !vm.rt.zeitpunkt().1 { - eprintln!("Zeitzone nicht ermittelbar — Zeitfunktionen rechnen in UTC."); - } - let event = vm.run(host); - let event = - if event == RunEvent::Ended && vm.forms.has_visible_forms() && !vm.is_terminated() { - vm.run_visible_forms(host) - } else { - event - }; - match event { - RunEvent::Restart { program, line } => { - if let Some(program) = program { - current = run_target(&path, &program).map_err(|error| { - eprintln!("{error}"); - ExitCode::from(1) - })?; - } - start_line = line; - } - event => return Ok((event, vm)), - } - } + tb_runner::run(Path::new(first), &args[1..].join(" "), |path| { + compile(Some(&path.display().to_string())).map(|(_, module)| module) + }) } -/// Host ohne Terminal: für Pipes und Skripte (`tbc run x.bas < eingabe.txt`). -/// Zeigt während des Laufs nichts an; die Ausgabe entsteht am Ende aus dem -/// Bildschirm-Snapshot. Tastendrücke kommen zeilenweise von stdin. -struct PipeHost { - puffer: std::collections::VecDeque, - eof: bool, - start: std::time::Instant, -} - -impl PipeHost { - fn new() -> Self { - let mut host = PipeHost { - puffer: std::collections::VecDeque::new(), - eof: false, - start: std::time::Instant::now(), - }; - use std::io::{IsTerminal, Read}; - let mut stdin = std::io::stdin(); - if !stdin.is_terminal() { - let mut input = String::new(); - let _ = stdin.read_to_string(&mut input); - for line in input.split_inclusive('\n') { - for character in line.trim_end_matches(['\r', '\n']).chars() { - host.puffer - .push_back(Ereignis::Taste(character.to_string(), 0)); - } - host.puffer.push_back(Ereignis::Taste( - tb_runtime::host::taste::ENTER.to_string(), - 0, - )); - } - host.puffer.push_back(Ereignis::Ende); - host.eof = true; - } - host - } - - /// Eine Zeile von stdin in Tastendrücke zerlegen. - fn nachfuellen(&mut self) { - use std::io::BufRead; - if self.eof { - return; - } - let mut zeile = String::new(); - match std::io::stdin().lock().read_line(&mut zeile) { - Ok(0) | Err(_) => { - self.eof = true; - self.puffer.push_back(Ereignis::Ende); - } - Ok(_) => { - while zeile.ends_with('\n') || zeile.ends_with('\r') { - zeile.pop(); - } - for c in zeile.chars() { - self.puffer.push_back(Ereignis::Taste(c.to_string(), 0)); - } - self.puffer.push_back(Ereignis::Taste( - tb_runtime::host::taste::ENTER.to_string(), - 0, - )); - } - } - } -} - -impl Host for PipeHost { - fn present(&mut self, _screen: &tb_runtime::screen::TextScreen) {} - - fn next_event(&mut self, blockierend: bool) -> Option { - if self.puffer.is_empty() && blockierend { - self.nachfuellen(); - } - self.puffer.pop_front() - } - - fn warten(&mut self, deadline_ms: Option) -> Option { - if let Some(event) = self.next_event(false) { - return Some(event); - } - if let Some(deadline) = deadline_ms { - std::thread::sleep(std::time::Duration::from_millis( - deadline.saturating_sub(self.jetzt_ms()), - )); - None - } else { - self.next_event(true).or(Some(Ereignis::Ende)) - } - } - - fn jetzt_ms(&mut self) -> u64 { - self.start.elapsed().as_millis() as u64 - } +#[cfg(test)] +fn run_chain( + args: &[String], + host: &mut dyn tb_runtime::host::Host, + size: Option<(usize, usize)>, +) -> Result<(tb_vm::interp::RunEvent, tb_vm::interp::Vm), ExitCode> { + let first = args.first().ok_or(ExitCode::from(1))?; + tb_runner::run_chain( + Path::new(first), + &args[1..].join(" "), + |path| compile(Some(&path.display().to_string())).map(|(_, module)| module), + host, + size, + ) } #[cfg(test)] diff --git a/crates/tb-cli/tests/project.rs b/crates/tb-cli/tests/project.rs index 8ab5d78..0c11ef4 100644 --- a/crates/tb-cli/tests/project.rs +++ b/crates/tb-cli/tests/project.rs @@ -329,3 +329,58 @@ fn designer_frm_is_accepted_by_the_standalone_cli_compiler() { tb_vm::project_io::load_program(&dir.join("designed.tbc")).unwrap(); std::fs::remove_dir_all(dir).unwrap(); } + +#[test] +fn native_build_optionen_sind_explizit_und_tbc_bleibt_standard() { + let dir = std::env::temp_dir().join(format!("tb-native-options-{}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + struct Cleanup(std::path::PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(dir.clone()); + std::fs::write(dir.join("main.bas"), "PRINT 42\nEND\n").unwrap(); + assert!(tbc(&dir, "build", "main.bas").status.success()); + let original = std::fs::read(dir.join("main.tbc")).unwrap(); + assert_eq!(&original[..4], b"TBC\0"); + for (options, diagnostic) in [ + (vec!["--bogus"], "Unbekannte Build-Option"), + (vec!["--exe", "--target"], "Wert fehlt"), + ( + vec!["--exe", "--target", "x86_64-apple-darwin"], + "Nicht unterstütztes Target", + ), + (vec!["--exe", "--exe"], "Doppeltes --exe"), + (vec!["--output", "program"], "benötigen --exe"), + ( + vec!["--exe", "--template", "missing", "-o", "program"], + "Runtime-Vorlage fehlt", + ), + ( + vec![ + "--exe", + "--target", + "aarch64-apple-darwin", + "--target", + "aarch64-apple-darwin", + ], + "Doppelte Option", + ), + ] { + let out = std::process::Command::new(env!("CARGO_BIN_EXE_tbc")) + .current_dir(&dir) + .args(["build", "main.bas"]) + .args(options) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1), "{out:?}"); + assert!( + String::from_utf8_lossy(&out.stderr).contains(diagnostic), + "{out:?}" + ); + assert_eq!(std::fs::read(dir.join("main.tbc")).unwrap(), original); + assert!(!dir.join("program").exists()); + } +} diff --git a/crates/tb-export/Cargo.toml b/crates/tb-export/Cargo.toml new file mode 100644 index 0000000..88cbe46 --- /dev/null +++ b/crates/tb-export/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "tb-export" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +tb-vm.workspace = true +anyhow.workspace = true diff --git a/crates/tb-export/src/bin/tb-template.rs b/crates/tb-export/src/bin/tb-template.rs new file mode 100644 index 0000000..7776b1a --- /dev/null +++ b/crates/tb-export/src/bin/tb-template.rs @@ -0,0 +1,12 @@ +//! Builder-Hilfe: geprüfte Metadaten für eine frisch gebaute Runtime schreiben. +fn main() -> anyhow::Result<()> { + let args: Vec<_> = std::env::args().skip(1).collect(); + anyhow::ensure!( + args.len() == 2, + "Aufruf: tb-template " + ); + tb_export::prepare_template( + std::path::Path::new(&args[0]), + tb_export::Target::parse(&args[1])?, + ) +} diff --git a/crates/tb-export/src/lib.rs b/crates/tb-export/src/lib.rs new file mode 100644 index 0000000..e259e6d --- /dev/null +++ b/crates/tb-export/src/lib.rs @@ -0,0 +1,514 @@ +//! 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::ALL + .into_iter() + .find(|t| t.triple() == s) + .with_context(|| format!("Nicht unterstütztes Target: {s}")) + } + pub fn host() -> Result { + 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 { + Ok(u16::from_le_bytes(part(b, at, 2)?.try_into()?)) +} +fn u32_at(b: &[u8], at: usize) -> Result { + Ok(u32::from_le_bytes(part(b, at, 4)?.try_into()?)) +} +fn u64_at(b: &[u8], at: usize) -> Result { + Ok(u64::from_le_bytes(part(b, at, 8)?.try_into()?)) +} +fn size_at(b: &[u8], at: usize) -> Result { + 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> { + 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 { + 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 { + 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 { + 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> { + 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>> { + 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, 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; diff --git a/crates/tb-export/src/tests.rs b/crates/tb-export/src/tests.rs new file mode 100644 index 0000000..bc416c5 --- /dev/null +++ b/crates/tb-export/src/tests.rs @@ -0,0 +1,226 @@ +use super::*; +use std::cell::Cell; + +fn header(t: Target) -> Vec { + let mut b = vec![0; 256]; + match t { + Target::WindowsAmd64 => { + b[..2].copy_from_slice(b"MZ"); + b[0x3c..0x40].copy_from_slice(&64u32.to_le_bytes()); + b[64..68].copy_from_slice(b"PE\0\0"); + b[68..70].copy_from_slice(&0x8664u16.to_le_bytes()); + b[86..88].copy_from_slice(&2u16.to_le_bytes()); + b[88..90].copy_from_slice(&0x20bu16.to_le_bytes()); + b[156..158].copy_from_slice(&3u16.to_le_bytes()); + } + Target::MacosArm64 => { + b[..4].copy_from_slice(&[0xcf, 0xfa, 0xed, 0xfe]); + b[4..8].copy_from_slice(&0x0100000cu32.to_le_bytes()); + b[12..16].copy_from_slice(&2u32.to_le_bytes()); + } + Target::LinuxAmd64 | Target::LinuxArm64 => { + b[..7].copy_from_slice(b"\x7fELF\x02\x01\x01"); + b[16..18].copy_from_slice(&2u16.to_le_bytes()); + let cpu: u16 = if t == Target::LinuxAmd64 { 62 } else { 183 }; + b[18..20].copy_from_slice(&cpu.to_le_bytes()); + } + } + b.extend_from_slice(&runtime_marker(t)); + b +} +struct Dir(PathBuf); +impl Dir { + fn new() -> Self { + let p = std::env::temp_dir().join(format!( + "tb-export-test-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&p).unwrap(); + Self(p) + } +} +impl Drop for Dir { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } +} + +#[test] +fn targets_versions_integrity_and_payload_bounds_are_enforced() { + let d = Dir::new(); + for t in Target::ALL { + let b = header(t); + assert_eq!(native_target(&b).unwrap(), t); + let path = d.0.join(t.runtime_name()); + fs::write(&path, &b).unwrap(); + prepare_template(&path, t).unwrap(); + assert_eq!(template(&path, t).unwrap(), b); + let other = if t == Target::LinuxAmd64 { + Target::LinuxArm64 + } else { + Target::LinuxAmd64 + }; + assert!(template(&path, other) + .unwrap_err() + .to_string() + .contains("Architektur")); + let meta = manifest(&b, t); + for wrong in [ + meta.replace("runtime=1", "runtime=2"), + meta.replace("package=0.1.0", "package=9"), + meta.replace("tbc=4", "tbc=99"), + meta.replace("checksum=", "checksum=0"), + ] { + fs::write(manifest_path(&path), wrong).unwrap(); + assert!(template(&path, t).is_err()); + } + fs::remove_file(manifest_path(&path)).unwrap(); + assert!(template(&path, t).is_err()); + fs::remove_file(&path).unwrap(); + assert!(template(&path, t) + .unwrap_err() + .to_string() + .contains("fehlt")); + for len in 0..64 { + assert!(native_target(&b[..len]).is_err()); + } + } + assert!(Target::parse("aarch64-pc-windows-msvc").is_err()); + assert!(Target::parse("x86_64-apple-darwin").is_err()); + let mut foreign_abi = header(Target::LinuxAmd64); + foreign_abi[7] = 9; + assert!(native_target(&foreign_abi).is_err()); + let module = tb_vm::compile_source("TEST", "PRINT 42\nEND\n").unwrap(); + for t in [Target::WindowsAmd64, Target::LinuxAmd64, Target::LinuxArm64] { + let mut b = header(t); + if t == Target::WindowsAmd64 { + let mut signed = b.clone(); + signed[196..200].copy_from_slice(&5u32.to_le_bytes()); + signed[232] = 1; + assert!(append_payload(&mut signed, &module, t) + .unwrap_err() + .to_string() + .contains("Signierte PE")); + b[152..156].copy_from_slice(&123u32.to_le_bytes()); + } + append_payload(&mut b, &module, t).unwrap(); + if t == Target::WindowsAmd64 { + assert_eq!(&b[152..156], &[0; 4]); + } + assert_eq!(embedded(&b, t).unwrap().to_tbc(), module.to_tbc()); + let at = b.len() - FOOTER; + for field in [8, 12, 16, 20, 24, 32, 40] { + let mut bad = b.clone(); + bad[at + field] ^= 0xff; + assert!(embedded(&bad, t).is_err(), "{field}"); + } + for cut in 1..=FOOTER + 1 { + assert!(embedded(&b[..b.len() - cut], t).is_err()); + } + let mut bad = b.clone(); + bad[300] ^= 1; + assert!(embedded(&bad, t).is_err()); + } +} + +#[test] +fn publication_preserves_originals_on_conflict_failure_race_and_cancel() { + let d = Dir::new(); + let out = d.0.join("result"); + let source = d.0.join("source.bas"); + fs::write(&source, b"source").unwrap(); + fs::write(&out, b"old").unwrap(); + assert!(publish(&out, false, &[], &|| false, |_| panic!( + "darf nicht schreiben" + )) + .is_err()); + assert!(publish( + &source, + true, + std::slice::from_ref(&source), + &|| false, + |_| panic!("Projektschutz") + ) + .is_err()); + assert!(publish(&out, true, &[], &|| false, |p| { + fs::write(p, b"new")?; + bail!("Finalisierungsfehler") + }) + .is_err()); + assert_eq!(fs::read(&out).unwrap(), b"old"); + let cancel = Cell::new(false); + assert!(publish(&out, true, &[], &|| cancel.get(), |p| { + fs::write(p, b"new")?; + cancel.set(true); + Ok(()) + }) + .is_err()); + assert_eq!(fs::read(&out).unwrap(), b"old"); + assert!(publish(&out, true, &[], &|| true, |_| panic!("abgebrochen")).is_err()); + assert!(publish(&out, true, &[], &|| false, |p| { + fs::write(p, b"new")?; + fs::write(&out, b"external")?; + Ok(()) + }) + .is_err()); + assert_eq!(fs::read(&out).unwrap(), b"external"); + fs::remove_file(&out).unwrap(); + assert!(publish(&out, true, &[], &|| false, |p| { + fs::write(p, b"new")?; + fs::write(&out, b"racer")?; + Ok(()) + }) + .is_err()); + assert_eq!(fs::read(&out).unwrap(), b"racer"); + assert!( + publish(&d.0.join("missing/result"), false, &[], &|| false, |_| Ok( + () + )) + .is_err() + ); + publish(&out, true, &[], &|| false, |p| { + fs::write(p, b"approved")?; + Ok(()) + }) + .unwrap(); + assert_eq!(fs::read(&out).unwrap(), b"approved"); + assert_eq!(fs::read(&source).unwrap(), b"source"); + assert_eq!(fs::read_dir(&d.0).unwrap().count(), 2); + #[cfg(unix)] + { + std::os::unix::fs::symlink(&source, d.0.join("alias")).unwrap(); + assert!( + publish(&d.0.join("alias"), true, &[], &|| false, |_| panic!( + "Symlink" + )) + .is_err() + ); + } +} + +#[test] +fn fehlgeschlagene_native_finalisierung_erhaelt_das_ziel() { + let dir = Dir::new(); + let runtime = dir.0.join("tbrt"); + let out = dir.0.join("program"); + fs::write(&runtime, header(Target::MacosArm64)).unwrap(); + prepare_template(&runtime, Target::MacosArm64).unwrap(); + fs::write(&out, b"original").unwrap(); + let module = tb_vm::compile_source("TEST", "END\n").unwrap(); + let error = export( + Export { + module: &module, + target: Target::MacosArm64, + template: &runtime, + output: &out, + overwrite: true, + protected: &[], + }, + &|| false, + ) + .unwrap_err(); + assert!(error.to_string().contains("Finalisierung"), "{error:#}"); + assert_eq!(fs::read(&out).unwrap(), b"original"); + assert_eq!(fs::read_dir(&dir.0).unwrap().count(), 3); +} diff --git a/crates/tb-runner/Cargo.toml b/crates/tb-runner/Cargo.toml new file mode 100644 index 0000000..61b5fd3 --- /dev/null +++ b/crates/tb-runner/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "tb-runner" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +tb-vm.workspace = true +tb-runtime.workspace = true +tb-export.workspace = true +tb-ui = { workspace = true, features = ["terminal"] } diff --git a/crates/tb-runner/src/bin/tbrt.rs b/crates/tb-runner/src/bin/tbrt.rs new file mode 100644 index 0000000..799b880 --- /dev/null +++ b/crates/tb-runner/src/bin/tbrt.rs @@ -0,0 +1,58 @@ +//! Native Runtime: kein IDE- oder BASIC-Quellcompiler-Einstieg. +use std::process::ExitCode; +#[used] +static MARKER: [u8; 32] = tb_export::runtime_marker(if cfg!(target_os = "windows") { + tb_export::Target::WindowsAmd64 +} else if cfg!(target_os = "macos") { + tb_export::Target::MacosArm64 +} else if cfg!(target_arch = "aarch64") { + tb_export::Target::LinuxArm64 +} else { + tb_export::Target::LinuxAmd64 +}); +fn main() -> ExitCode { + std::hint::black_box(&MARKER); + let path = match std::env::current_exe() { + Ok(p) => p, + Err(e) => { + eprintln!("Executable nicht auffindbar: {e}"); + return ExitCode::from(1); + } + }; + let bytes = match std::fs::read(&path) { + Ok(b) => b, + Err(e) => { + eprintln!("Executable nicht lesbar: {e}"); + return ExitCode::from(1); + } + }; + let target = match tb_export::Target::host() { + Ok(t) => t, + Err(e) => { + eprintln!("{e}"); + return ExitCode::from(1); + } + }; + if let Err(e) = tb_export::embedded(&bytes, target) { + eprintln!("Ladefehler: {e:#}"); + return ExitCode::from(1); + } + let command = std::env::args().skip(1).collect::>().join(" "); + tb_runner::run(&path, &command, |current| { + let result = if current == path { + tb_export::embedded(&bytes, target).map_err(|e| format!("{e:#}")) + } else if tb_vm::project_io::has_extension(current, "tbc") { + std::fs::read(current) + .map_err(|e| e.to_string()) + .and_then(|b| { + tb_vm::bytecode::CompiledModule::from_tbc(&b).map_err(|e| e.to_string()) + }) + } else { + Err("tbrt benötigt ein vorkompiliertes externes RUN-Ziel (.tbc); kein BASIC-Quellcompiler enthalten".into()) + }; + result.map_err(|e| { + eprintln!("{}: Ladefehler: {e}", current.display()); + ExitCode::from(1) + }) + }) +} diff --git a/crates/tb-runner/src/lib.rs b/crates/tb-runner/src/lib.rs new file mode 100644 index 0000000..935f7c5 --- /dev/null +++ b/crates/tb-runner/src/lib.rs @@ -0,0 +1,204 @@ +//! Gemeinsamer CLI-/Standalone-Runner; der Aufrufer bestimmt das Ladeverfahren. +use std::path::Path; +use std::process::ExitCode; +use tb_runtime::host::{Ereignis, Host}; +use tb_ui::host::TerminalHost; +use tb_vm::bytecode::CompiledModule; +use tb_vm::interp::{RunEvent, Vm}; +use tb_vm::project_io::{new_execution, run_target}; + +pub fn run( + first: &Path, + command: &str, + mut load: impl FnMut(&Path) -> Result, +) -> ExitCode { + let result = match TerminalHost::new() { + Ok(mut host) => { + let size = host.groesse().ok(); + let result = run_chain(first, command, &mut load, &mut host, size); + drop(host); + result + } + Err(_) => run_chain(first, command, &mut load, &mut PipeHost::new(), None), + }; + finish(result) +} + +fn finish(result: Result<(RunEvent, Vm), ExitCode>) -> ExitCode { + let (ereignis, vm) = match result { + Ok(result) => result, + Err(code) => return code, + }; + print!("{}", tb_runtime::snapshot::text(&vm.rt.screen)); + // `LPRINT` sammelt im Druckerpuffer; am Programmende geht er in die + // Datei LPT1.TXT im aktuellen Verzeichnis (dokumentierte Abweichung — + // einen Druckerkanal gibt es plattformübergreifend nicht). + if !vm.rt.print.drucker.is_empty() { + if let Err(e) = std::fs::write("LPT1.TXT", &vm.rt.print.drucker) { + eprintln!("Druckerausgabe nicht schreibbar: {e}"); + } + } + + match ereignis { + RunEvent::Ended => ExitCode::SUCCESS, + RunEvent::Stopped { line } => { + // STOP außerhalb der IDE: Meldung + Exit-Code ≠ 0 (D6). + eprintln!("{}:{line}: STOP in line {line}", vm.current_file()); + ExitCode::from(3) + } + RunEvent::Error { + code, + line, + message, + } => { + eprintln!( + "{}:{line}:{}: Runtime error {code}: {message} in line {line}", + vm.current_file(), + vm.current_source_pos().column + ); + ExitCode::from(2) + } + // Ohne Debugger-Flags treten diese Ereignisse nicht auf. + RunEvent::Interrupted { line } => { + eprintln!("Abgebrochen in Zeile {line}"); + ExitCode::from(3) + } + // Ohne Debugger-Flags treten diese Ereignisse nicht auf. + RunEvent::Breakpoint { .. } | RunEvent::Stepped { .. } => ExitCode::from(2), + RunEvent::Restart { .. } => unreachable!("RUN wird vom Runner aufgelöst"), + } +} + +pub fn run_chain( + first: &Path, + command: &str, + mut load: impl FnMut(&Path) -> Result, + host: &mut dyn Host, + size: Option<(usize, usize)>, +) -> Result<(RunEvent, Vm), ExitCode> { + let mut current = first.to_path_buf(); + let mut start_line = None; + loop { + let module = load(¤t)?; + let mut vm = new_execution(module, command, size, start_line.take()).map_err(|error| { + eprintln!("Runtime error {}: {}", error.0, error); + ExitCode::from(2) + })?; + if !vm.rt.zeitpunkt().1 { + eprintln!("Zeitzone nicht ermittelbar — Zeitfunktionen rechnen in UTC."); + } + let event = vm.run(host); + let event = + if event == RunEvent::Ended && vm.forms.has_visible_forms() && !vm.is_terminated() { + vm.run_visible_forms(host) + } else { + event + }; + match event { + RunEvent::Restart { program, line } => { + if let Some(program) = program { + current = run_target(¤t, &program).map_err(|error| { + eprintln!("{error}"); + ExitCode::from(1) + })?; + } + start_line = line; + } + event => return Ok((event, vm)), + } + } +} + +/// Host ohne Terminal: für Pipes und Skripte (`tbc run x.bas < eingabe.txt`). +/// Zeigt während des Laufs nichts an; die Ausgabe entsteht am Ende aus dem +/// Bildschirm-Snapshot. Tastendrücke kommen zeilenweise von stdin. +struct PipeHost { + puffer: std::collections::VecDeque, + eof: bool, + start: std::time::Instant, +} + +impl PipeHost { + fn new() -> Self { + let mut host = PipeHost { + puffer: std::collections::VecDeque::new(), + eof: false, + start: std::time::Instant::now(), + }; + use std::io::{IsTerminal, Read}; + let mut stdin = std::io::stdin(); + if !stdin.is_terminal() { + let mut input = String::new(); + let _ = stdin.read_to_string(&mut input); + for line in input.split_inclusive('\n') { + for character in line.trim_end_matches(['\r', '\n']).chars() { + host.puffer + .push_back(Ereignis::Taste(character.to_string(), 0)); + } + host.puffer.push_back(Ereignis::Taste( + tb_runtime::host::taste::ENTER.to_string(), + 0, + )); + } + host.puffer.push_back(Ereignis::Ende); + host.eof = true; + } + host + } + + /// Eine Zeile von stdin in Tastendrücke zerlegen. + fn nachfuellen(&mut self) { + use std::io::BufRead; + if self.eof { + return; + } + let mut zeile = String::new(); + match std::io::stdin().lock().read_line(&mut zeile) { + Ok(0) | Err(_) => { + self.eof = true; + self.puffer.push_back(Ereignis::Ende); + } + Ok(_) => { + while zeile.ends_with('\n') || zeile.ends_with('\r') { + zeile.pop(); + } + for c in zeile.chars() { + self.puffer.push_back(Ereignis::Taste(c.to_string(), 0)); + } + self.puffer.push_back(Ereignis::Taste( + tb_runtime::host::taste::ENTER.to_string(), + 0, + )); + } + } + } +} + +impl Host for PipeHost { + fn present(&mut self, _screen: &tb_runtime::screen::TextScreen) {} + + fn next_event(&mut self, blockierend: bool) -> Option { + if self.puffer.is_empty() && blockierend { + self.nachfuellen(); + } + self.puffer.pop_front() + } + + fn warten(&mut self, deadline_ms: Option) -> Option { + if let Some(event) = self.next_event(false) { + return Some(event); + } + if let Some(deadline) = deadline_ms { + std::thread::sleep(std::time::Duration::from_millis( + deadline.saturating_sub(self.jetzt_ms()), + )); + None + } else { + self.next_event(true).or(Some(Ereignis::Ende)) + } + } + + fn jetzt_ms(&mut self) -> u64 { + self.start.elapsed().as_millis() as u64 + } +} diff --git a/crates/tb-vm/src/project.rs b/crates/tb-vm/src/project.rs index 1fdde83..a74f799 100644 --- a/crates/tb-vm/src/project.rs +++ b/crates/tb-vm/src/project.rs @@ -849,9 +849,14 @@ fn link( Ok((result, debug_maps)) } +// Erst ein ausdrücklich erzeugter DebugCompiler bindet den Quellcompiler ein. +// Die reine Runtime kann dadurch den gleichen VM-Pfad ohne Parser/Codegenerator linken. +type DebugCompileFn = fn(&DebugCompiler, u16, &str, &str, bool) -> Result; + /// Ephemeral symbol/link context, deliberately not part of TBC serialization. #[derive(Clone)] pub struct DebugCompiler { + compile_fn: DebugCompileFn, modules: Vec<(Module, FormCatalog, DebugMap)>, symbols: tb_frontend::sema::DebugSymbols, slots: Vec, @@ -914,6 +919,7 @@ impl ProjectCompiler { } } DebugCompiler { + compile_fn: DebugCompiler::compile_impl, modules, symbols, slots, @@ -928,6 +934,15 @@ impl DebugCompiler { procedure: &str, text: &str, expression: bool, + ) -> Result { + (self.compile_fn)(self, module, procedure, text, expression) + } + fn compile_impl( + &self, + module: u16, + procedure: &str, + text: &str, + expression: bool, ) -> Result { if let Some(error) = &self.error { return Err(error.clone()); diff --git a/docs/native-executables.md b/docs/native-executables.md new file mode 100644 index 0000000..ce6511a --- /dev/null +++ b/docs/native-executables.md @@ -0,0 +1,145 @@ +# Native Terminalprogramme mit tbrt + +`tbc build` erzeugt weiterhin `.tbc`. Für ein direkt startbares Programm: + +```sh +tbc build projekt.mak --exe --target aarch64-apple-darwin \ + --template /pfad/runtimes/aarch64-apple-darwin/tbrt -o meinprogramm +./meinprogramm erstes "zweites Argument" +``` + +`--exe` funktioniert für BAS, FRM, MAK und bereits kompilierte TBC-Projekte. +`-o`/`--output` bestimmt die Ausgabe; ohne Angabe wird die Quellendung unter +Windows durch `.exe` ersetzt, unter Linux/macOS entfernt. `--target` wählt +explizit ein Target, andernfalls gilt das unterstützte Host-Target. +Ohne `--template` wird neben `tbc` unter +`runtimes//tbrt` beziehungsweise `tbrt.exe` gesucht. +Die zugehörige Datei `.meta` ist erforderlich. + +Vorhandene Ausgaben bleiben erhalten; `--force` erlaubt ihr ausdrückliches +Ersetzen. Quellen, Includes, Projektmanifest, Eingabekompilat und Vorlage +sind als Ausgaben geschützt. Unbekannte/doppelte Optionen und fehlende +Werte werden abgewiesen. Native Ausgabeoptionen benötigen `--exe`. + +## Ziel- und Systemvoraussetzungen + +| Ziel | Target-Triple | Vorlage | Finalisierung | +| --- | --- | --- | --- | +| Windows amd64 | `x86_64-pc-windows-msvc` | `tbrt.exe` | PE32+ Console; keine Finalisierungs-Toolchain | +| macOS arm64 | `aarch64-apple-darwin` | `tbrt` | macOS mit `/usr/bin/codesign`, Ad-hoc-Signatur und strikte Prüfung | +| Linux amd64 | `x86_64-unknown-linux-gnu` | `tbrt` | ELF64; keine Finalisierungs-Toolchain | +| Linux arm64 | `aarch64-unknown-linux-gnu` | `tbrt` | ELF64; keine Finalisierungs-Toolchain | + +Erzeugung aus einer gelieferten Vorlage und Ausführung benötigen kein Rust, +Cargo, keinen C-Compiler und keinen nativen Linker. Der Export lädt keine +Vorlage herunter und startet keinen Cargo-Ersatzbuild. Ohne passende Vorlage, +Metadaten oder Finalisierungsvoraussetzung gibt es einen Fehler vor der +Veröffentlichung. macOS-Export ist deshalb auf macOS beschränkt; vorhandene +PE-/ELF-Vorlagen können auch auf anderen unterstützten Hosts verpackt werden. + +Standalone enthält die native VM/Runtime und Terminal-/Forms-Unterstützung. +Das BASIC-Projekt bleibt P-Code. Systembibliotheken des jeweiligen Builders +bleiben Betriebssystemvoraussetzungen: Linux verwendet das GNU/glibc-Target, +Windows die MSVC-/Windows-Systembasis, macOS seine Systembibliotheken. Eine +konkrete Linux-Mindestversion wird erst mit dem realen Gitea-Builder-Image +in Change 06 festgelegt; beliebig alte Systeme sind nicht zugesichert. +Die lokale Ad-hoc-Signatur ersetzt keine Developer-ID-Notarisierung. + +## Laufzeitvertrag + +Die einzelne Ausgabedatei enthält Projekt, Forms-Anfangswerte und physische +Quellorte. Originalquellen, eine separate TBC-Datei und installiertes tb/tbc +werden zum Start nicht gebraucht. `COMMAND$` verbindet die übergebenen +Argumente wie `tbc run`. Terminal und Pipe benutzen denselben Runner wie +CLI: Pipe-Eingaben kommen von stdin, der abschließende Textsnapshot von +stdout; `LPRINT` erzeugt `LPT1.TXT` im Arbeitsverzeichnis. + +Exitcodes: 0 bei normalem Ende, 3 bei STOP/Abbruch, 2 bei BASIC-Laufzeitfehlern, +1 bei Lade-/Bedienfehlern. Die leere Vorlage endet mit einem Ladefehler. +`RUN` ohne Namen lädt den eingebetteten Anfangsstand erneut; nach einem +externen RUN startet ein weiterer namenloser RUN das nun aktive Kompilat neu. + +`tbrt` hat keinen BASIC-Quellcompiler. Benannte externe RUN-Ziele müssen +vorkompilierte `.tbc` sein, zum Beispiel `RUN "NEXT.TBC"`. Sie werden wie beim +CLI relativ zum aktiven Programm und ohne Beachtung der Dateinamen-Großschreibung +aufgelöst. Ohne Endung gilt weiterhin die bestehende Suchfolge BAS/FRM/MAK/TBC; +liegt dort eine Quelle statt eines Kompilats, benennt tbrt die fehlende +Vorkompilierung. Externe Dateien für RUN, OPEN, ISAM und SHELL werden nicht +mit eingebettet und müssen ausdrücklich mitgeliefert werden. + +## Vorlagen bauen (Entwickler und Actions) + +Auf einem für das jeweilige Rust-Target eingerichteten Builder: + +```sh +python3 tests/support/build-runtime.py --target aarch64-apple-darwin \ + --output dist/runtimes/aarch64-apple-darwin +``` + +Derselbe Aufruf gilt mit jedem Triple aus der Tabelle. Windows baut nur +amd64, macOS nur arm64, Linux beide Architekturen. Das Skript führt +`cargo build --locked --release --target -p tb-runner --bin tbrt` +aus und lässt die native Header-/Architekturprüfung durch `tb-template` +die Metadaten schreiben. Es installiert keine Cross-Toolchain. Ein bereits +vorhandenes Ausgabeverzeichnis wird nicht verändert; bei einem Fehler wird +die neu angelegte Ausgabe entfernt. Gitea-Releasepakete folgen in Change 06 +(Windows `.7z`, Linux/macOS `.tar.gz`). + +Metadatenformat 1 bindet Target, Runtime-ABI 1, Paketversion, TBC-Version und +FNV-1a-64 der exakten Vorlagendatei. Zusätzlich trägt tbrt eine eingebundene +Runtime-/Format-/Target-Markierung. FNV erkennt Dateibeschädigung und +Verwechslung; es authentifiziert keine Herkunft. Vorlagen und Metadaten +werden zusammen aus demselben vertrauenswürdigen Release bezogen. + +## Container und Export-API für Changes 03/04/06 + +`tb_export::export(Export { module, target, template, output, overwrite, +protected }, cancelled)` erhält den fertig verknüpften Projektstand, keine +IDE-Dokumenttypen. `protected` enthält insbesondere das Eingabemanifest; +Quell-/Include-Pfade aus dem Kompilat sowie Vorlage/Metadaten ergänzt der +Export selbst. Die IDE kann ihren bestehenden Abbruch-/Revisionszustand +über den Callback prüfen. `publish` stellt denselben Schutz für TBL/Link +bereit: temporäre Geschwisterdatei, vollständige Finalisierung, sync, +Abbruch- und Zielkonfliktprüfung, danach Veröffentlichung. Ein neues Ziel +wird mit atomarem No-clobber angelegt. Ein vorhandenes Ziel wird nur nach +Freigabe und unverändertem Inhalt ersetzt. CLI-SIGINT/SIGTERM beziehungsweise +Windows-Ctrl+C/Break setzen den Abbruchzustand; temporäre Dateien werden +beim kontrollierten Fehlerpfad entfernt. Unabfangbares Prozessende wie +SIGKILL ist keine kontrollierte Abbruchbehandlung. + +Nutzlastabschluss: 48 Bytes, Little Endian, `TBPCODE!`, Container-Version +(u32), Runtime-ABI (u32), TBC-Version (u32), Target-ID (u32), Offset (u64), +Länge (u64), FNV-1a-64 der Nutzlast (u64). Vor dem Start werden native +Architektur, Versionen, Grenzen, Prüfsumme und anschließend der bestehende +TBC-Ladervertrag geprüft. Fehler führen nicht zur Ausführung von Projektcode. + +ELF/PE verwenden den Abschluss hinter der Nutzlast. PE-Vorlagen müssen +unsigniert sein; eine vorhandene Authenticode-Signatur wird ausdrücklich +abgewiesen, die optionale PE-Prüfsumme auf 0 gesetzt. Auf macOS wird die alte +Signatur vor dem Einbetten entfernt, `__LINKEDIT` um Nutzlast/Abschluss +erweitert und danach mit codesign neu signiert und strikt geprüft. Der +Lader findet den Abschluss vor dem durch `LC_CODE_SIGNATURE` ausgewiesenen +Signaturbereich einschließlich maximal 15 Nullbytes Ausrichtung; er nimmt +kein unveränderliches Dateiende an. Die fertige Datei wird erneut geladen, +bevor sie veröffentlicht wird. Apple beschreibt, dass Signieren das +Executable verändert und spätere Änderungen die Signatur ungültig machen: +[Code Signing In Depth](https://developer.apple.com/library/archive/technotes/tn2206/_index.html). + +## Reproduzierbare lokale Abnahme + +```sh +cargo build --locked --release -p tb-cli -p tb-runner -p tb-export +python3 tests/support/native-abnahme.py --target aarch64-apple-darwin +cargo test --locked --workspace +cargo clippy --locked --workspace --all-targets -- -D warnings +cargo fmt --all -- --check +``` + +Die native Probe vergleicht CLI und EXE für Konsole/COMMAND$/Eingaben/LPRINT, +RUN-Neustart, STOP, Fehler, Mehrmodul-/Include-/Dateieffekte, Forms und +externes TBC-RUN. Vor nativer Ausführung werden die Originalquellen entfernt; +PATH ist leer. macOS prüft jede Signatur. Unix-PTY prüft echte Terminaleingabe, +Abbruch und Wiederherstellung; zusätzliche Fälle prüfen Exportabbruch und +beschädigte Nutzlasten. Kopf-/Architekturtests für alle vier Targets sind +Formatnachweise; tatsächliche Windows-/Linux-Ausführung und deren +Terminalmatrix stehen in Changes 05/06 aus. diff --git a/openspec/changes/archive/2026-09-07-phase-6-01-kompatibilitaet-und-leistungsabnahme/phase-6-uebersicht.md b/openspec/changes/archive/2026-09-07-phase-6-01-kompatibilitaet-und-leistungsabnahme/phase-6-uebersicht.md index 2084022..56f90c6 100644 --- a/openspec/changes/archive/2026-09-07-phase-6-01-kompatibilitaet-und-leistungsabnahme/phase-6-uebersicht.md +++ b/openspec/changes/archive/2026-09-07-phase-6-01-kompatibilitaet-und-leistungsabnahme/phase-6-uebersicht.md @@ -38,12 +38,12 @@ plant die gesamte Phase 6; es behauptet keine neue Implementation oder Abnahme. | Nr. | Change | Voraussetzungen | Eigenständige Leistung | | --- | --- | --- | --- | | 01 | [Kompatibilität und Leistungsabnahme](proposal.md) | Phase 5 | Reproduzierbarer Abnahmesatz, Fremdkorpus, Inventarbasis und begründeter Performance-Pass | -| 02 | [Native Executables](../phase-6-02-native-executables/proposal.md) | 01 | Gemeinsamer Export-/Runnerpfad, vier native Programmziele, sichere Veröffentlichung | -| 03 | [P-Code-Bibliotheken und Linker](../phase-6-03-pcode-bibliotheken-und-linker/proposal.md) | 02 | TBL-Format, tbc link, MAK-Einbindung und quellfreie BASIC-Verbraucher | -| 04 | [IDE-Exportanbindung](../phase-6-04-ide-exportanbindung/proposal.md) | 02, 03 | Reale Make-Aktionen, TBL-Projektintegration, konsistenter Overlay-/Librarystand und sichere Ergebnisse | -| 05 | [Plattformmatrix](../phase-6-05-plattformmatrix/proposal.md) | 01–04 | Tatsächliche Eingabe-/Darstellungs-/Terminalabnahme auf allen Zielen | -| 06 | [Gitea Actions und Releases](../phase-6-06-gitea-actions-und-releases/proposal.md) | 01–05 | Vier Actions-Builds, Paketierung, native Paketprüfungen und vollständiger Releasepfad | -| 07 | [Dokumentation und Phasenabnahme](../phase-6-07-dokumentation-und-phasenabnahme/proposal.md) | 01–06 | Aktuelle Anleitungen/Hilfe, ausgelieferter Gesamtweg und belegter Phase-6-Abschluss | +| 02 | [Native Executables](../2026-09-07-phase-6-02-native-executables/proposal.md) | 01 | Gemeinsamer Export-/Runnerpfad, vier native Programmziele, sichere Veröffentlichung | +| 03 | [P-Code-Bibliotheken und Linker](../../phase-6-03-pcode-bibliotheken-und-linker/proposal.md) | 02 | TBL-Format, tbc link, MAK-Einbindung und quellfreie BASIC-Verbraucher | +| 04 | [IDE-Exportanbindung](../../phase-6-04-ide-exportanbindung/proposal.md) | 02, 03 | Reale Make-Aktionen, TBL-Projektintegration, konsistenter Overlay-/Librarystand und sichere Ergebnisse | +| 05 | [Plattformmatrix](../../phase-6-05-plattformmatrix/proposal.md) | 01–04 | Tatsächliche Eingabe-/Darstellungs-/Terminalabnahme auf allen Zielen | +| 06 | [Gitea Actions und Releases](../../phase-6-06-gitea-actions-und-releases/proposal.md) | 01–05 | Vier Actions-Builds, Paketierung, native Paketprüfungen und vollständiger Releasepfad | +| 07 | [Dokumentation und Phasenabnahme](../../phase-6-07-dokumentation-und-phasenabnahme/proposal.md) | 01–06 | Aktuelle Anleitungen/Hilfe, ausgelieferter Gesamtweg und belegter Phase-6-Abschluss | Die Reihenfolge 01 bis 07 ist gültig. Native Zielprüfungen in 02/03/05 sind vor 06 zunächst lokal auf passenden Prüfrechnern ausführbar; 06 automatisiert diff --git a/openspec/changes/phase-6-02-native-executables/.openspec.yaml b/openspec/changes/archive/2026-09-07-phase-6-02-native-executables/.openspec.yaml similarity index 100% rename from openspec/changes/phase-6-02-native-executables/.openspec.yaml rename to openspec/changes/archive/2026-09-07-phase-6-02-native-executables/.openspec.yaml diff --git a/openspec/changes/phase-6-02-native-executables/design.md b/openspec/changes/archive/2026-09-07-phase-6-02-native-executables/design.md similarity index 100% rename from openspec/changes/phase-6-02-native-executables/design.md rename to openspec/changes/archive/2026-09-07-phase-6-02-native-executables/design.md diff --git a/openspec/changes/phase-6-02-native-executables/proposal.md b/openspec/changes/archive/2026-09-07-phase-6-02-native-executables/proposal.md similarity index 87% rename from openspec/changes/phase-6-02-native-executables/proposal.md rename to openspec/changes/archive/2026-09-07-phase-6-02-native-executables/proposal.md index 73d4e21..3bf0fce 100644 --- a/openspec/changes/phase-6-02-native-executables/proposal.md +++ b/openspec/changes/archive/2026-09-07-phase-6-02-native-executables/proposal.md @@ -22,4 +22,4 @@ Keine. TBC bleibt ein gesondertes Ausgabeformat mit unverändertem Sprachvertrag ## Impact -CLI-Build-/Runnerpfad in `crates/tb-cli/src/main.rs`, gemeinsame Projekt-/VM-Erzeugung, neuer wiederverwendbarer Export-/Runnerbereich, Release-Vorlagen und native Prozessprüfungen. Keine LLVM-/JIT-Umstellung. Referenzfälle aus [01](../archive/2026-09-07-phase-6-01-kompatibilitaet-und-leistungsabnahme/proposal.md); Auslieferung in 06. Der gleiche Exportvertrag wird von 03 und 04 verwendet. +CLI-Build-/Runnerpfad in `crates/tb-cli/src/main.rs`, gemeinsame Projekt-/VM-Erzeugung, neuer wiederverwendbarer Export-/Runnerbereich, Release-Vorlagen und native Prozessprüfungen. Keine LLVM-/JIT-Umstellung. Referenzfälle aus [01](../2026-09-07-phase-6-01-kompatibilitaet-und-leistungsabnahme/proposal.md); Auslieferung in 06. Der gleiche Exportvertrag wird von 03 und 04 verwendet. diff --git a/openspec/changes/phase-6-02-native-executables/specs/native-executables/spec.md b/openspec/changes/archive/2026-09-07-phase-6-02-native-executables/specs/native-executables/spec.md similarity index 100% rename from openspec/changes/phase-6-02-native-executables/specs/native-executables/spec.md rename to openspec/changes/archive/2026-09-07-phase-6-02-native-executables/specs/native-executables/spec.md diff --git a/openspec/changes/phase-6-02-native-executables/tasks.md b/openspec/changes/archive/2026-09-07-phase-6-02-native-executables/tasks.md similarity index 71% rename from openspec/changes/phase-6-02-native-executables/tasks.md rename to openspec/changes/archive/2026-09-07-phase-6-02-native-executables/tasks.md index 61d43d2..a02f6a0 100644 --- a/openspec/changes/phase-6-02-native-executables/tasks.md +++ b/openspec/changes/archive/2026-09-07-phase-6-02-native-executables/tasks.md @@ -1,19 +1,19 @@ ## 1. Gemeinsamer Ausführungs- und Exportpfad -- [ ] 1.1 CLI-Laufsteuerung für Quellen, TBC und eingebettete Kompilate wiederverwendbar machen; bestehende CLI-/IDE-Parität, Forms-Ende, STOP-/Fehlerexitcodes, LPRINT und RUN-Regressionen müssen bestehen. -- [ ] 1.2 Ziel-/Runtime-/Vorlagenmetadaten für die vier vereinbarten Targets sowie kompatible Versionen festlegen; negative Tests müssen falsches System, Architektur, Version und fehlende Vorlage konkret abweisen. -- [ ] 1.3 Gemeinsame geschützte Ausgabeveröffentlichung implementieren; Konflikt, Schreibfehler, zwischenzeitlich belegtes Ziel und Abbruch müssen ursprüngliche Dateien und Projektdaten erhalten. +- [x] 1.1 CLI-Laufsteuerung für Quellen, TBC und eingebettete Kompilate wiederverwendbar machen; bestehende CLI-/IDE-Parität, Forms-Ende, STOP-/Fehlerexitcodes, LPRINT und RUN-Regressionen müssen bestehen. +- [x] 1.2 Ziel-/Runtime-/Vorlagenmetadaten für die vier vereinbarten Targets sowie kompatible Versionen festlegen; negative Tests müssen falsches System, Architektur, Version und fehlende Vorlage konkret abweisen. +- [x] 1.3 Gemeinsame geschützte Ausgabeveröffentlichung implementieren; Konflikt, Schreibfehler, zwischenzeitlich belegtes Ziel und Abbruch müssen ursprüngliche Dateien und Projektdaten erhalten. ## 2. Native Programme -- [ ] 2.1 Das kleine native `tbrt` mit VM/Runtime/Terminal/Forms und versioniertem eingebettetem Kompilat bauen; eine leere Vorlage muss fehlende Nutzlast diagnostizieren, ohne Originalquellen müssen Konsolen- und Forms-Programme laufen, RUN ohne Ziel muss das eingebettete Projekt neu initialisieren. -- [ ] 2.2 Nutzlastgrenzen/-versionen prüfen und PE/ELF-/Mach-O-Ausgaben korrekt finalisieren; beschädigte Längen und abgeschnittene Nutzlast müssen ohne Absturz mit Ladefehler enden. -- [ ] 2.3 macOS-arm64-Signierung und stabile Nutzlastauffindung nach Finalisierung praktisch belegen; native Ausführung und Signaturprüfung müssen die endgültige Datei akzeptieren, andernfalls keine Exportfreigabe. -- [ ] 2.4 `tbc build --exe` mit Ausgabepfad, explizitem Target und Überschreibentscheidung anbinden; CLI-Tests müssen unbekannte Flags abweisen und den bisherigen TBC-Standard unverändert bestätigen. -- [ ] 2.5 `tbrt`-Vorlagen-Bauaufrufe für Windows amd64, macOS arm64 und Linux amd64/arm64 bereitstellen; native Header-/Architekturprüfung muss jede Vorlage ihrem deklarierten Target zuordnen. +- [x] 2.1 Das kleine native `tbrt` mit VM/Runtime/Terminal/Forms und versioniertem eingebettetem Kompilat bauen; eine leere Vorlage muss fehlende Nutzlast diagnostizieren, ohne Originalquellen müssen Konsolen- und Forms-Programme laufen, RUN ohne Ziel muss das eingebettete Projekt neu initialisieren. +- [x] 2.2 Nutzlastgrenzen/-versionen prüfen und PE/ELF-/Mach-O-Ausgaben korrekt finalisieren; beschädigte Längen und abgeschnittene Nutzlast müssen ohne Absturz mit Ladefehler enden. +- [x] 2.3 macOS-arm64-Signierung und stabile Nutzlastauffindung nach Finalisierung praktisch belegen; native Ausführung und Signaturprüfung müssen die endgültige Datei akzeptieren, andernfalls keine Exportfreigabe. +- [x] 2.4 `tbc build --exe` mit Ausgabepfad, explizitem Target und Überschreibentscheidung anbinden; CLI-Tests müssen unbekannte Flags abweisen und den bisherigen TBC-Standard unverändert bestätigen. +- [x] 2.5 `tbrt`-Vorlagen-Bauaufrufe für Windows amd64, macOS arm64 und Linux amd64/arm64 bereitstellen; native Header-/Architekturprüfung muss jede Vorlage ihrem deklarierten Target zuordnen. ## 3. Abnahme und Übergabe -- [ ] 3.1 Repräsentative Fälle aus 01 als einzelne Executables in sauberen Verzeichnissen ohne Quellen/TBC/tb/tbc/Rust-Toolchain starten; Ausgabe, Dateieffekte, COMMAND$, Fehler und Terminal-/Pipeverhalten müssen mit CLI übereinstimmen. -- [ ] 3.2 Verfügbarkeit und Systemvoraussetzungen einschließlich externer RUN-/OPEN-Ressourcen dokumentieren; fehlende Finalisierungswerkzeuge dürfen keine stille Ersatzdatei erzeugen. -- [ ] 3.3 Gemeinsamen Exportvertrag und Zielvorlagen an 03/04/06 übergeben; fokussierte native Tests, Workspace-Regressionen, Clippy, Format- und OpenSpec-Prüfung müssen bestehen, noch ausstehende externe Zielnachweise benannt sein. +- [x] 3.1 Repräsentative Fälle aus 01 als einzelne Executables in sauberen Verzeichnissen ohne Quellen/TBC/tb/tbc/Rust-Toolchain starten; Ausgabe, Dateieffekte, COMMAND$, Fehler und Terminal-/Pipeverhalten müssen mit CLI übereinstimmen. +- [x] 3.2 Verfügbarkeit und Systemvoraussetzungen einschließlich externer RUN-/OPEN-Ressourcen dokumentieren; fehlende Finalisierungswerkzeuge dürfen keine stille Ersatzdatei erzeugen. +- [x] 3.3 Gemeinsamen Exportvertrag und Zielvorlagen an 03/04/06 übergeben; fokussierte native Tests, Workspace-Regressionen, Clippy, Format- und OpenSpec-Prüfung müssen bestehen, noch ausstehende externe Zielnachweise benannt sein. diff --git a/openspec/changes/archive/2026-09-07-phase-6-02-native-executables/verification.md b/openspec/changes/archive/2026-09-07-phase-6-02-native-executables/verification.md new file mode 100644 index 0000000..ac712a7 --- /dev/null +++ b/openspec/changes/archive/2026-09-07-phase-6-02-native-executables/verification.md @@ -0,0 +1,141 @@ +# Verifizierung: phase-6-02-native-executables + +Stand: 2026-09-07, Arbeitsbaum auf `993c3e7638f4819615ce6d1eea2c984afb29a1f2`. +Der vorausgehende Change 01 wurde synchronisiert, archiviert und mit diesem +Commit auf `origin/main` bestätigt. Dieser Bericht betrifft Change 02. + +## Ergebnis + +| Dimension | Nachweis | +| --- | --- | +| Vollständigkeit | 11/11 Aufgaben; 4/4 Requirements | +| Korrektheit | Alle 6 Spec-Szenarien abgedeckt; native macOS-arm64-Ausführung bestanden | +| Kohärenz | Gemeinsamer Runner, bestehende VM/TBC/Forms-Semantik, vorgebaute Runtime und geschützter Export gemäß Design | +| Offene Befunde | 0 CRITICAL, 0 WARNING, 0 SUGGESTION | + +Proposal, Design, Tasks, Delta-Spec, betroffene Ausführungspfade und +Regressionen wurden geprüft. Keine Prüfdimension übersprungen. Die nach +Design ausdrücklich späteren realen Windows-/Linux-Abnahmen sind unten +getrennt benannt; sie werden nicht als durchgeführt ausgegeben. + +## Requirement- und Szenariozuordnung + +| Requirement / Szenario | Implementierung und ausgeführter Nachweis | +| --- | --- | +| Eigenständiges natives Terminalprogramm / Leere Runtime-Vorlage | `crates/tb-runner/src/bin/tbrt.rs:13` liest sein eigenes Executable; `tb_export::embedded` prüft vor VM-Start. Native Probe: leere Vorlage liefert Exit 1 und Nutzlastdiagnose. | +| Eigenständiges natives Terminalprogramm / Ausführung auf sauberem Ziel | `crates/tb-export/src/lib.rs:471`, `tests/support/native-abnahme.py`: sieben CLI-/EXE-Vergleiche, darunter ein Forms-/Mehrmodulprojekt mit verschachtelten Includes. Quellen vor EXE-Start gelöscht, separates Arbeitsverzeichnis, PATH leer. Fehlerorte, Anfangswerte, Ausgabe und Dateieffekte identisch. | +| Explizites Ausgabeformat und Ziel / Vorlage passt nicht | `crates/tb-cli/src/main.rs:140`: Standard bleibt TBC; explizite EXE-/Target-/Vorlagen-/Ausgabe-/Force-Optionen. `native_build_optionen_sind_explizit_und_tbc_bleibt_standard` sowie Exporttests prüfen unbekannte/doppelte/fehlende Optionen, fremde Systeme/Architekturen, Runtime-/Paket-/TBC-Version, Integrität und fehlende Vorlage. | +| Gemeinsame Laufzeitsemantik / Neustart aus dem Executable | `crates/tb-runner/src/lib.rs:10` und `:72` werden von CLI und tbrt benutzt. Native Probe verändert Variable/Datei und führt RUN aus: frische VM, Datei bleibt, COMMAND$ bleibt. Externes TBC-RUN, STOP, Runtimefehler, LPRINT, Eingaben, Forms und Terminalabbruch bestehen ebenfalls. | +| Validiertes und sicher veröffentlichtes Artefakt / Beschädigte Nutzlast | `crates/tb-export/src/lib.rs:179`: Container-/Runtime-/TBC-/Targetprüfung, geprüfte Offset-/Längenarithmetik, Prüfsumme und TBC-Validierung. Native Gegenproben für maximale u64-Länge, falsche Version und abgeschnittene Nutzlast enden mit Exit 1/Ladefehler ohne Programmausgabe. | +| Validiertes und sicher veröffentlichtes Artefakt / Finalisierung schlägt fehl | `crates/tb-export/src/lib.rs:346`: temporäres Geschwisterziel, Schutzpfade, Abbruch, Zielvergleich und atomare Veröffentlichung. `fehlgeschlagene_native_finalisierung_erhaelt_das_ziel` lässt die tatsächliche native Finalisierung scheitern und prüft Original/Bereinigung. Weitere Gegenproben decken Schreibfehler, Konflikt, nachträglich belegtes Ziel und Abbruch ab. | + +## Behobene Befunde aus der Umsetzung und Verifizierung + +1. **Indirekter Quellcompiler in tbrt.** Designentscheidung 1 fordert einen + Runner ohne BASIC-Quellcompiler. Die erste Symbolprüfung fand den Parser + über den Watch-/Debugger-Pfad der gemeinsamen VM. `DebugCompiler` bindet + den Compiler nun erst bei seiner ausdrücklichen Erzeugung über einen + Funktionszeiger (`crates/tb-vm/src/project.rs`). Die VM-API und + Debuggerfunktion bleiben erhalten. Erneute Release-Symbolprüfung mit + `nm` findet keine Parser-, SourceLoader-, Projektcompiler- oder + Debug-Compiler-Implementierungssymbole; IDE-/VM-Debuggertests bestehen. +2. **Abbruch während CLI-Export.** Ein nur für Bibliotheksaufrufer vorhandener + Abbruchcallback genügte nicht für CLI-Signale. `export_cancel.rs` führt + Unix-SIGINT/SIGTERM beziehungsweise Windows-Ctrl+C/Break in diesen + Callback. Die native Prozessprobe unterbricht zwei laufende Exporte + nach Anlage der temporären Datei: Exit 1, Original unverändert, keine + temporäre Ausgabe. Laufzeit-Ctrl+C bleibt beim bestehenden Terminalhost. +3. **Native Formatgrenzen.** Gegenproben schärften abgeschnittene Header und + fremde ELF-System-ABIs. Kurze Header und andere ABIs als System V/Linux + werden abgewiesen. PE übernimmt keine veraltete optionale Prüfsumme; + vorhandene Authenticode-Signaturen führen zu einer konkreten Ablehnung. + Vier Target-Zuordnungen und beschädigte Header/Nutzlasten sind geprüft. +4. **Mach-O-Ende nach Signierung.** Eine reine Dateiende-Annahme wäre nach + codesign falsch. Die Implementierung entfernt die vorherige Signatur, + erweitert `__LINKEDIT`, signiert nach dem Einbetten und liest vor der + Veröffentlichung die fertige Datei erneut. Der Lader benutzt die durch + `LC_CODE_SIGNATURE` bezeichnete Grenze und geprüftes Nullpadding. + Export, `codesign --verify --strict` und native Ausführung bestehen. + +Keine bestehenden Golden-Dateien oder Inventarverträge wurden geändert. +Die CLI-/IDE-Abnahme benutzt weiterhin denselben Ausführungspfad; nur ihre +Imports wurden nach Herauslösen des Runners ausdrücklich gemacht. + +## Ausgeführte Gates + +Lokaler Rechner: Apple M5 Max, 128 GiB, macOS 26.6.2 (25G83), +`aarch64-apple-darwin`, Rust 1.97.1/Cargo 1.97.1. Release-Profil mit LTO +und codegen-units 1. Vorlagen-/Container-Runtime-ABI 1, TBC-Version 4. + +```sh +cargo build --locked --release -p tb-cli -p tb-runner -p tb-export +python3 tests/support/native-abnahme.py --target aarch64-apple-darwin +python3 tests/support/release-abnahme.py --vbdos-repo /tmp/terminalbasic-vbdos-evidence +cargo check --locked --target aarch64-unknown-linux-gnu -p tb-cli -p tb-runner -p tb-export +openspec validate --all --strict +git diff --check +``` + +| Gate | Ergebnis | +| --- | --- | +| Native CLI-/EXE-Probe | 7 Vergleichsfälle bestanden: Konsole/COMMAND$/INPUT/LPRINT, RUN-Neustart, STOP, Laufzeitfehler/Export aus TBC, Projekt-/Include-/Dateifehler, Forms-/Mehrmodul-/Include-Projekt und externes TBC-RUN | +| Terminal | 4 Unix-PTY-Läufe: CLI und EXE jeweils Eingabe bzw. Ctrl+C; korrekte Exitcodes und Terminalattribute wiederhergestellt | +| Veröffentlichung | Überschreibverweigerung und ausdrückliches Ersetzen; echte SIGINT-/SIGTERM-Abbrüche erhalten das Original und entfernen temporäre Dateien | +| Ladefehler | 3 beschädigte native Nutzlasten ohne Projektcode-Ausführung abgewiesen; zusätzliche Versions-/Grenz-/Prüfsummen-Gegenproben in Rust | +| macOS-Signierung | Alle finalisierten Vergleichsprogramme strikt geprüft und nativ gestartet | +| Workspace | 613 Tests plus 1 Offline-Kindprozesstest bestanden, 0 fehlgeschlagen; 2 beabsichtigte Ignores | +| Öffentlicher Pflichtnachweis | Separat 3 Tests bestanden, darin 7 Einstiege × 2 Läufe von cout/vbdos bei `1cdd2b32b829fe1721d0b6aecc433abc47a96fb6` | +| Inventar | 845 Einträge: 795 implementiert, 0 offen, 50 deklarierte Non-Features; keine Umklassifizierung | +| Clippy / Format | Workspace/all-targets mit `-D warnings` und Formatprüfung bestanden | +| OpenSpec | 30 Specs/Changes strikt bestanden, 0 fehlgeschlagen; bestehende INFO-Textlängenhinweise | +| Linux arm64 | Cargo-Cross-Check bestanden; kein nativer Ausführungsnachweis | +| Vorlagen-Builder | `build-runtime.py --target aarch64-apple-darwin --output /tmp/tb-phase6-02-evidence/templates/aarch64-apple-darwin` erfolgreich, native Header/Architektur und Metadaten geprüft | + +Die native Probe setzt PATH bereits beim Export leer; codesign wird über +seinen absoluten Systempfad aufgerufen. Die ausgeführten Programme benötigen +somit keine auflösbaren Compiler-/Linkerwerkzeuge. Das ist ein isolierter +lokaler Prozessnachweis, keine Behauptung eines frisch installierten +Betriebssystems. Die externe RUN-Probe enthält bewusst ihr zusätzliches +TBC-Laufzeitmodul; die übrigen Programme benötigen keine separate TBC-Datei. + +Beschädigte Mach-O-Proben werden nach der absichtlichen Mutation neu +signiert, damit der Nutzlastlader geprüft wird. Eine ungültige native +Signatur kann bereits vom Betriebssystem vor main abgewiesen werden. +Die lokale Ad-hoc-Signatur beweist Ausführbarkeit, keine Notarisierung. + +Das finale macOS-tbrt ist 3.022.416 Bytes groß. `otool -L` weist ausschließlich +`/usr/lib/libiconv.2.dylib` und `/usr/lib/libSystem.B.dylib` als dynamische +Abhängigkeiten aus; keine separate Terminal-Basic-Runtime und keine IDE. + +## Leistungsregression der gemeinsamen VM + +Die unveränderten Benchmarks aus Change 01 wurden im vollständigen +Abnahmelauf erneut ausgeführt. Modul: 1,20 ms bei Budget < 50 ms; Projekt +mit 49.760 Zeilen: 100 ms bei Budget < 1.000 ms. Inkrementelle Mediane: +0,72 ms und 20,62 ms. VM: INTEGER 1.193 ms, DOUBLE 556 ms, SUB 138 ms, +Strings 110 ms. Gegenüber der dokumentierten Basis 1.217/572/139/109 ms +kein relevanter Leistungsrückschritt; keine Performance-Optimierung. + +## Übergabe und bewusst spätere Zielnachweise + +[Native Executables](../../../../docs/native-executables.md) dokumentiert +CLI-Optionen, Vorlagenherstellung für alle vier vereinbarten Triples, +Metadaten/Container, öffentliche Export-API und Systemvoraussetzungen. +`tbrt` lädt externe RUN-Ziele als vorkompilierte TBC; Quellen benötigen die +vorherige Übersetzung durch tbc. RUN-/OPEN-/ISAM-/SHELL-Ressourcen werden +nicht automatisch eingebettet. Diese Grenze folgt dem geplanten Runner +ohne BASIC-Quellcompiler und ist ausdrücklich sichtbar. + +Die tatsächliche Windows-amd64- und Linux-amd64/arm64-Ausführung, +Windows-Terminal-/Abbruchprüfung, konkrete glibc-Builderbasis sowie vier +Gitea-Releasepakete bleiben gemäß Design bei Changes 05/06. Change 02 liefert +Bauaufrufe, Header-/Architekturprüfung und lokalen macOS-Nativnachweis. +TBL/Linker und IDE-Anbindung verwenden denselben Exportvertrag in 03/04. + +Lokale Rohprotokolle: `/tmp/tb-phase6-02-evidence/`, insbesondere +`native.log`, `release-abnahme.log`, `export-tests.log`, +`linux-arm64-check.log` und `template-build.log`. Alle wesentlichen +Resultate und reproduzierbaren Aufrufe stehen zusätzlich in diesem Bericht. +Am 2026-09-07 wurden die vier Requirements in die Hauptspezifikation +`native-executables` synchronisiert, alle 25 Hauptspezifikationen strikt +validiert und Change 02 archiviert. diff --git a/openspec/changes/phase-6-03-pcode-bibliotheken-und-linker/proposal.md b/openspec/changes/phase-6-03-pcode-bibliotheken-und-linker/proposal.md index 0272a64..524e841 100644 --- a/openspec/changes/phase-6-03-pcode-bibliotheken-und-linker/proposal.md +++ b/openspec/changes/phase-6-03-pcode-bibliotheken-und-linker/proposal.md @@ -22,4 +22,4 @@ Keine. TBL ist eine additive Projekt-/Buildfähigkeit; die Anpassung der bisheri ## Impact -`tb-vm/src/project.rs`, `bytecode.rs`, `project_io.rs`, Frontend-Deklarationsimport und `tb-cli/src/main.rs`; Wiederverwendung der sicheren Veröffentlichung und `tbrt`-Verpackung aus [02](../phase-6-02-native-executables/proposal.md). Abhängigkeit: 02. IDE-Dokumentmodell, Ausführung, Debugger und Make-Anbindung folgen in 04; plattformübergreifende Verbraucherprüfungen in 05/06, Dokumentation und Gesamtabnahme in 07. Dieser Entwurf ersetzt den noch nicht implementierten Entwurf `phase-6-03-native-bibliotheken` vollständig. +`tb-vm/src/project.rs`, `bytecode.rs`, `project_io.rs`, Frontend-Deklarationsimport und `tb-cli/src/main.rs`; Wiederverwendung der sicheren Veröffentlichung und `tbrt`-Verpackung aus [02](../archive/2026-09-07-phase-6-02-native-executables/proposal.md). Abhängigkeit: 02. IDE-Dokumentmodell, Ausführung, Debugger und Make-Anbindung folgen in 04; plattformübergreifende Verbraucherprüfungen in 05/06, Dokumentation und Gesamtabnahme in 07. Dieser Entwurf ersetzt den noch nicht implementierten Entwurf `phase-6-03-native-bibliotheken` vollständig. diff --git a/openspec/specs/native-executables/spec.md b/openspec/specs/native-executables/spec.md new file mode 100644 index 0000000..c624c6f --- /dev/null +++ b/openspec/specs/native-executables/spec.md @@ -0,0 +1,43 @@ +# native-executables Specification + +## Purpose + +Ermöglicht die Erzeugung und Ausführung eigenständiger nativer Terminalprogramme mit eingebettetem Terminal-Basic-Projekt und Runtime. + +## Requirements + +### Requirement: Eigenständiges natives Terminalprogramm +`tbc build --exe` SHALL ein natives Terminal-Executable für Windows amd64, macOS arm64, Linux amd64 oder Linux arm64 erzeugen. Die vorgebaute `tbrt`-Vorlage SHALL die nativ kompilierte VM und Runtime einschließlich Terminal-/Forms-Unterstützung enthalten und das eingebettete BASIC-Programm als P-Code ausführen. Das Ergebnis SHALL auf dem ausgewiesenen Ziel ohne Quellprojekt, separate TBC-Datei oder separat installiertes tbrt/tb/tbc laufen. Sowohl Erzeugung aus ausgelieferten Vorlagen als auch Ausführung SHALL ohne Compiler-/Linker-Toolchain beim Anwender möglich sein; dokumentierte Betriebssystemkomponenten dürfen erforderlich bleiben. + +#### Scenario: Leere Runtime-Vorlage +- **WHEN** tbrt ohne eingebettetes Programm als Vorlage gestartet wird +- **THEN** diagnostiziert es kontrolliert die fehlende Nutzlast und behauptet keinen erfolgreichen Programmstart + +#### Scenario: Ausführung auf sauberem Ziel +- **WHEN** ein Mehrmodul-/Formular-/Include-Projekt exportiert und allein sein Executable in ein Verzeichnis ohne Quellen und ohne tb/tbc übertragen wird +- **THEN** läuft es auf dem passenden Zielsystem als Terminalprogramm mit denselben Anfangseigenschaften, Ausgaben und Fehlerorten + +### Requirement: Explizites Ausgabeformat und Ziel +Der bestehende Aufruf `tbc build ` SHALL weiterhin TBC erzeugen. Native Erzeugung SHALL eine eindeutige Ziel-/Ausgabewahl und Überschreibentscheidung erlauben. Ungültige Optionen, nicht unterstützte Zielkombinationen, fehlende Vorlagen und inkompatible Runtime-Versionen MUST vor Veröffentlichung einer Zieldatei mit konkreter Diagnose abgewiesen werden. Ein fremdes Hostformat MUST NOT stillschweigend das angeforderte Zielformat ersetzen. + +#### Scenario: Vorlage passt nicht +- **WHEN** eine Windows-amd64-Ausgabe mit einer Linux- oder inkompatiblen Runtime-Vorlage angefordert wird +- **THEN** scheitert der Export unter Erhalt der bisherigen Zieldatei und nennt den Ziel-/Versionskonflikt + +### Requirement: Gemeinsame Laufzeitsemantik +Native Programme SHALL bei denselben Hostereignissen, Anfangsdateien, Größen und COMMAND$ dieselben Sprach-, Forms-, Datei-, Druck- und Fehlerresultate wie der CLI-Lauf liefern. STOP, Abbruch und Laufzeitfehler SHALL die dokumentierten CLI-Exitcodes erhalten. RUN ohne Ziel SHALL den eingebetteten Anfangsstand neu starten; explizite externe RUN-Ziele SHALL weiter ausdrücklich benötigte Laufzeitdateien sein. + +#### Scenario: Neustart aus dem Executable +- **WHEN** das native Programm Variablen und Dateien verändert und danach RUN ohne Programmziel ausführt +- **THEN** startet es sein eingebettetes Projekt mit sauberem VM-Anfangszustand und benötigt dafür keine ursprüngliche BAS-/FRM-/MAK-Datei + +### Requirement: Validiertes und sicher veröffentlichtes Artefakt +Der Export SHALL Nutzlastgrenzen, Container-/Runtime-Version und Zielformat prüfen und erst ein vollständiges Artefakt am gewählten Ziel veröffentlichen. Fehler und Abbruch SHALL vorhandene Ziele erhalten und temporäre Ausgaben beseitigen. Ausführungsrechte sowie die für lokale Ausführbarkeit notwendigen Plattform-Metadaten und Signaturen SHALL nach dem Einbetten gültig sein. + +#### Scenario: Beschädigte Nutzlast +- **WHEN** ein erzeugtes Programm mit abgeschnittener oder ungültiger eingebetteter Nutzlast gestartet wird +- **THEN** endet es mit verständlicher Ladefehlermeldung ohne Zugriff außerhalb der Nutzlast und ohne Ausführung von Projektcode + +#### Scenario: Finalisierung schlägt fehl +- **WHEN** die plattformspezifische Finalisierung eines Exports fehlschlägt +- **THEN** wird kein Erfolg gemeldet und eine vorhandene Zieldatei bleibt unverändert diff --git a/tests/support/build-runtime.py b/tests/support/build-runtime.py new file mode 100644 index 0000000..c35be13 --- /dev/null +++ b/tests/support/build-runtime.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Builder-Aufruf für eine geprüfte tbrt-Vorlage; keine Cross-Toolchain-Installation.""" +import argparse +import os +from pathlib import Path +import shutil +import subprocess + +TARGETS = ('x86_64-pc-windows-msvc', 'aarch64-apple-darwin', + 'x86_64-unknown-linux-gnu', 'aarch64-unknown-linux-gnu') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--target', choices=TARGETS, required=True) + parser.add_argument('--output', type=Path, required=True, + help='neues Verzeichnis für tbrt und tbrt.meta') + args = parser.parse_args() + output = args.output.resolve() + root = Path(__file__).resolve().parents[2] + if output.exists(): + parser.error(f'Ausgabeverzeichnis existiert bereits: {output}') + subprocess.run(['cargo', 'build', '--locked', '--release', '--target', args.target, + '-p', 'tb-runner', '--bin', 'tbrt'], cwd=root, check=True) + name = 'tbrt.exe' if args.target == TARGETS[0] else 'tbrt' + target_dir = Path(os.environ.get('CARGO_TARGET_DIR', root / 'target')) + if not target_dir.is_absolute(): + target_dir = root / target_dir + output.mkdir(parents=True) + try: + shutil.copy2(target_dir / args.target / 'release' / name, output / name) + subprocess.run(['cargo', 'run', '--locked', '--release', '-p', 'tb-export', + '--bin', 'tb-template', '--', str(output / name), args.target], + cwd=root, check=True) + except BaseException: + shutil.rmtree(output) + raise + print(output) + + +if __name__ == '__main__': + main() diff --git a/tests/support/native-abnahme.py b/tests/support/native-abnahme.py new file mode 100644 index 0000000..78f4c2a --- /dev/null +++ b/tests/support/native-abnahme.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Native CLI-/Standalone-Parität. Vorher cargo build --release für tbc/tbrt/tb-template.""" +import argparse +import os +from pathlib import Path +import shutil +import struct +import subprocess +import sys +import tempfile + + +def run(args, cwd, *, data=b'', env=None): + return subprocess.run([str(a) for a in args], cwd=cwd, input=data, + capture_output=True, timeout=30, env=env) + + +def must(result): + assert result.returncode == 0, (result.args, result.returncode, result.stdout, result.stderr) + return result + + +def terminal(binary, arguments, cwd, env, abort=False): + """Echte Unix-PTY, Eingabe/Abbruch und unveränderte Terminalattribute.""" + import fcntl + import select + import signal + import termios + import time + master, slave = os.openpty() + original = termios.tcgetattr(slave) + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 30, 100, 0, 0)) + pid = os.fork() + if pid == 0: + os.setsid() + fcntl.ioctl(slave, termios.TIOCSCTTY, 0) + for fd in (0, 1, 2): os.dup2(slave, fd) + os.close(master) + os.close(slave) + result = subprocess.run([str(binary), *map(str, arguments)], cwd=cwd, env=env, check=False) + restored = termios.tcgetattr(0) == original + os._exit(result.returncode if restored else 99) + transcript = bytearray() + sent = False + reaped = False + deadline = time.monotonic() + 15 + try: + while True: + if select.select([master], [], [], .05)[0]: + transcript.extend(os.read(master, 65536)) + if not sent and b'READY' in transcript: + os.write(master, b'\x03' if abort else b'Ada\r') + sent = True + child, status = os.waitpid(pid, os.WNOHANG) + if child: + reaped = True + assert os.waitstatus_to_exitcode(status) == (3 if abort else 0), bytes(transcript) + break + assert time.monotonic() < deadline, bytes(transcript[-2000:]) + assert sent and b'?1049h' in transcript and b'?1049l' in transcript, transcript + assert (b'Abgebrochen' if abort else b'PTYAda') in transcript, transcript + finally: + if not reaped: + os.killpg(pid, signal.SIGKILL) + os.waitpid(pid, 0) + os.close(master) + os.close(slave) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--bin-dir', type=Path, default=Path('target/release')) + parser.add_argument('--target', required=True) + args = parser.parse_args() + binaries = args.bin_dir.resolve() + suffix = '.exe' if args.target == 'x86_64-pc-windows-msvc' else '' + tbc = binaries / ('tbc' + suffix) + rt = binaries / ('tbrt' + suffix) + prepare = binaries / ('tb-template' + suffix) + env = dict(os.environ, PATH='') # weder Cargo noch tbc/tbrt über PATH auflösbar + with tempfile.TemporaryDirectory(prefix='tb-native-') as work: + root = Path(work) + template = root / ('runtime' + suffix) + shutil.copy2(rt, template) + must(run([prepare, template, args.target], root)) + empty = run([template], root, env=env) + assert empty.returncode == 1 and b'Nutzlast' in empty.stderr, empty + cases = { + 'console': ('PRINT "Hallo, Welt!"\nPRINT COMMAND$\nINPUT "Name"; n$\nPRINT n$\nLPRINT "Papier"\nEND\n', b'Ada\n'), + 'restart': ('OPEN "count.txt" FOR BINARY AS #1\nn%=LOF(1)\nCLOSE #1\nIF n%=0 THEN\nOPEN "count.txt" FOR OUTPUT AS #1\nPRINT #1, "einmal"\nCLOSE #1\nx%=99\nRUN\nEND IF\nPRINT x%; COMMAND$\nEND\n', b''), + 'stop': ('PRINT "vor STOP"\nSTOP\nPRINT "falsch"\n', b''), + 'error': ('PRINT "vor Fehler"\nERROR 6\n', b''), + 'project': ('CALL Ergebnis\nEND\n', b''), + 'form': ('VERSION 1.00\nBegin Form Form1\n Caption = "Native Form"\n Width = 40\n Height = 10\n Begin CommandButton Ok\n Caption = "OK"\n Default = -1\n Left = 2\n Top = 2\n Width = 10\n Height = 1\n End\nEnd\nForm1.Show\nSUB Ok_Click\nForm1.Hide\nCLS\nPRINT "FORMDONE"\nEND\nEND SUB\n', b'\n'), + 'external': ('RUN "NEXT.TBC"\n', b''), + } + for name, (code, data) in cases.items(): + case = root / name + sources, reference, clean = (case / p for p in ('sources', 'reference', 'clean')) + for d in (sources, reference, clean): d.mkdir(parents=True) + source = sources / ('main.frm' if name == 'form' else 'main.bas') + source.write_text(code, encoding='utf-8') + if name == 'project': + (sources / 'lib.bas').write_text("'$INCLUDE: 'OUTER.BI'\n") + (sources / 'outer.bi').write_text("'$INCLUDE: 'INNER.BI'\n") + (sources / 'inner.bi').write_text('SUB Ergebnis\nOPEN "seed.txt" FOR INPUT AS #1\nLINE INPUT #1, s$\nCLOSE #1\nOPEN "result.txt" FOR OUTPUT AS #2\nPRINT #2, s$\nCLOSE #2\nPRINT s$\nERROR 6\nEND SUB\n') + source = sources / 'app.mak' + source.write_text('MAIN.BAS\nLIB.BAS\n') + for d in (reference, clean): (d / 'seed.txt').write_text('Grüße\n', encoding='utf-8') + if name == 'form': + source.write_text(code.replace('PRINT "FORMDONE"', 'CALL Ergebnis'), encoding='utf-8') + (sources / 'lib.bas').write_text("'$INCLUDE: 'OUTER.BI'\n") + (sources / 'outer.bi').write_text("'$INCLUDE: 'INNER.BI'\n") + (sources / 'inner.bi').write_text('SUB Ergebnis\nPRINT "FORMDONE"\nEND SUB\n') + source = sources / 'app.mak' + source.write_text('MAIN.FRM\nLIB.BAS\n') + if name == 'external': + child = sources / 'next.bas' + child.write_text('PRINT "EXTERNAL"; COMMAND$\nEND\n') + must(run([tbc, 'build', child], root)) + shutil.copy2(child.with_suffix('.tbc'), clean / 'next.tbc') + expected = run([tbc, 'run', source, 'alpha', 'ä beta'], reference, data=data) + exe = clean / ('program' + suffix) + build_source = source + if name == 'error': + must(run([tbc, 'build', source], root)) + build_source = source.with_suffix('.tbc') + command = [tbc, 'build', build_source, '--exe', '--target', args.target, '--template', template, '-o', exe] + must(run(command, root, env=env)) + original = exe.read_bytes() + conflict = run(command, root, env=env) + assert conflict.returncode == 1 and b'--force' in conflict.stderr + assert exe.read_bytes() == original + must(run(command + ['--force'], root, env=env)) + if sys.platform == 'darwin': + must(run(['/usr/bin/codesign', '--verify', '--strict', exe], root)) + shutil.rmtree(sources) + actual = run([exe, 'alpha', 'ä beta'], clean, data=data, env=env) + assert (actual.returncode, actual.stdout, actual.stderr) == (expected.returncode, expected.stdout, expected.stderr), (name, expected, actual) + if name == 'form': assert b'FORMDONE' in actual.stdout, actual + if name == 'restart': assert b'99' not in actual.stdout and b'alpha' in actual.stdout, actual + if name == 'project': assert b'inner.bi:9:1:' in actual.stderr.lower(), actual + for file in reference.iterdir(): + assert (clean / file.name).read_bytes() == file.read_bytes(), file + assert sorted(p.name for p in clean.iterdir()) == sorted([exe.name] + [p.name for p in reference.iterdir()] + (['next.tbc'] if name == 'external' else [])) + print('PASS native', name, flush=True) + if os.name == 'posix': + source = root / 'terminal.bas' + source.write_text('PRINT "READY"\nINPUT s$\nPRINT "PTY"; s$\nEND\n') + exe = root / 'terminal' + must(run([tbc, 'build', source, '--exe', '--template', template, '--target', args.target, '-o', exe], root, env=env)) + for abort in (False, True): + terminal(tbc, ['run', source], root, env, abort) + source.unlink() + for abort in (False, True): + terminal(exe, [], root, env, abort) + print('PASS PTY Eingabe, Abbruch und Wiederherstellung (CLI/native)', flush=True) + if os.name == 'posix': + import signal + import time + source = root / 'cancel.bas' + source.write_text('PRINT "nicht veröffentlicht"\nEND\n') + output = root / 'cancelled' + for sig in (signal.SIGINT, signal.SIGTERM): + output.write_bytes(b'original') + process = subprocess.Popen([str(tbc), 'build', str(source), '--exe', '--template', str(template), '--target', args.target, '-o', str(output), '--force'], cwd=root, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + deadline = time.monotonic() + 10 + while not list(root.glob('.tb-export-*.tmp')): + assert process.poll() is None and time.monotonic() < deadline, 'Export endete vor Abbruchprobe' + time.sleep(.001) + process.send_signal(sig) + stdout, stderr = process.communicate(timeout=15) + assert process.returncode == 1 and b'abgebrochen' in stderr, (process.returncode, stdout, stderr) + assert output.read_bytes() == b'original' + assert not list(root.glob('.tb-export-*.tmp')) + finally: + if process.poll() is None: + process.kill() + process.wait() + print('PASS Exportabbruch SIGINT/SIGTERM, Original und Bereinigung', flush=True) + # Beschädigte Nutzlast wird auf macOS neu signiert, damit der Lader statt des Kernels prüft. + valid = (root / 'console/clean' / ('program' + suffix)).read_bytes() + footer = valid.rfind(b'TBPCODE!') + assert footer >= 0 + for name, mutate in ( + ('length', lambda b: struct.pack_into('