From 01b24dffdccccd9e257956e8c78e0407b3b31859 Mon Sep 17 00:00:00 2001 From: Steven Hildreth Date: Sun, 7 Jun 2026 17:29:45 -0500 Subject: [PATCH 1/9] feat(oxdoor): introduce V1 package format and inspection tooling Implement the initial version of the OXDOOR package format, a zip-based distribution standard for BBS doors. This includes the formal V1 specification, crate-level validation logic, and a new CLI command for inspecting package metadata and integrity. - Add OXDOOR V1 specification to design and documentation - Implement package parsing and validation in `oxidebbs-door` - Introduce `doors package inspect` subcommand to `oxidebbs-server` - Update sysop CLI documentation with package management details - Add dependencies for zip processing and SHA-256 checksums --- Cargo.lock | 3 + crates/oxidebbs-door/Cargo.toml | 3 + crates/oxidebbs-door/src/lib.rs | 23 + crates/oxidebbs-door/src/oxdoor_package.rs | 1118 ++++++++++++++++++ crates/oxidebbs-server/src/commands/doors.rs | 79 +- design/OXDOOR_FORMAT_V1.md | 542 +++++++++ docs/OXDOOR_FORMAT_V1.md | 20 + docs/project/doors.md | 4 +- docs/project/sysop-cli.md | 3 + 9 files changed, 1791 insertions(+), 4 deletions(-) create mode 100644 crates/oxidebbs-door/src/oxdoor_package.rs create mode 100644 design/OXDOOR_FORMAT_V1.md create mode 100644 docs/OXDOOR_FORMAT_V1.md diff --git a/Cargo.lock b/Cargo.lock index 0a24f20..1186716 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1912,10 +1912,13 @@ dependencies = [ name = "oxidebbs-door" version = "1.2.2" dependencies = [ + "hex", "oxidebbs-core", "serde", + "sha2 0.11.0", "thiserror 2.0.18", "toml", + "zip", ] [[package]] diff --git a/crates/oxidebbs-door/Cargo.toml b/crates/oxidebbs-door/Cargo.toml index 0741256..e4b81ca 100644 --- a/crates/oxidebbs-door/Cargo.toml +++ b/crates/oxidebbs-door/Cargo.toml @@ -6,7 +6,10 @@ license.workspace = true authors.workspace = true [dependencies] +hex.workspace = true oxidebbs-core = { path = "../oxidebbs-core" } serde.workspace = true +sha2.workspace = true thiserror.workspace = true toml.workspace = true +zip.workspace = true diff --git a/crates/oxidebbs-door/src/lib.rs b/crates/oxidebbs-door/src/lib.rs index 0a0c758..7245a71 100644 --- a/crates/oxidebbs-door/src/lib.rs +++ b/crates/oxidebbs-door/src/lib.rs @@ -12,12 +12,17 @@ use std::process::{Command, ExitStatus, Stdio}; use std::thread; use std::time::{Duration, Instant}; +mod oxdoor_package; use oxidebbs_core::door::DoorDefinition; use serde::Deserialize; use thiserror::Error; pub const CRATE_NAME: &str = "oxidebbs-door"; +pub use crate::oxdoor_package::{ + inspect_oxide_door_package, OxDoorPackageInspection, OxDoorPackageSummary, +}; + #[derive(Debug, Error)] pub enum DoorError { #[error("failed to read door config {path}: {source}")] @@ -40,6 +45,24 @@ pub enum DoorError { #[error("door process timed out after {timeout:?}")] Timeout { timeout: Duration }, + + #[error("failed to read door package {path}: {source}")] + ReadDoorPackage { + path: PathBuf, + source: std::io::Error, + }, + + #[error("failed to parse door package {path}: {source}")] + ParseDoorPackage { + path: PathBuf, + source: toml::de::Error, + }, + + #[error("invalid door package {path}: {message}")] + InvalidDoorPackage { + path: PathBuf, + message: String, + }, } #[derive(Debug, Clone, Deserialize)] diff --git a/crates/oxidebbs-door/src/oxdoor_package.rs b/crates/oxidebbs-door/src/oxdoor_package.rs new file mode 100644 index 0000000..432d1b3 --- /dev/null +++ b/crates/oxidebbs-door/src/oxdoor_package.rs @@ -0,0 +1,1118 @@ +use std::collections::{HashMap, HashSet}; +use std::fs::File; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; + +use hex; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use zip::ZipArchive; + +use crate::DoorError; + +pub const OXDOOR_MANIFEST_FILE: &str = "oxide-door.toml"; +pub const OXDOOR_CHECKSUM_FILE: &str = "checksums.sha256"; +pub const OXDOOR_PACKAGE_FORMAT: &str = "oxide-door-package-v1"; +pub const OXDOOR_PACKAGE_KIND_FULL: &str = "full"; +pub const OXDOOR_SUPPORTED_DROPFILES: [&str; 6] = [ + "DOOR.SYS", + "DORINFO1.DEF", + "CHAIN.TXT", + "DOORFILE.SR", + "PCBOARD.SYS", + "CALLINFO.BBS", +]; + +const FILES_DIRECTORY: &str = "files/"; + +#[derive(Debug, Clone, Serialize)] +pub struct OxDoorPackageSummary { + pub package_name: String, + pub package_id: String, + pub package_version: String, + pub package_kind: String, + pub legal_status: String, + pub requires_key: bool, + pub source_url: Option, + pub door_id: String, + pub door_name: String, + pub door_category: String, + pub runner: String, + pub command: String, + pub working_directory: String, + pub preferred_drop_file: String, + pub supported_drop_files: Vec, + pub exclusive: bool, + pub timeout_seconds: u64, + pub min_security_level: i32, + pub enabled_after_import_request: bool, + pub file_count: usize, + pub total_unpacked_size: u64, + pub warnings: Vec, +} + +#[derive(Debug, Clone)] +pub struct OxDoorPackageInspection { + pub summary: OxDoorPackageSummary, +} + +#[derive(Debug, Clone, Deserialize)] +struct RawDoorPackageManifest { + pub package: PackageSection, + pub legal: LegalSection, + #[serde(default)] + pub source: Option, + pub door: DoorSection, + #[serde(default)] + pub access: AccessSection, + #[serde(default)] + pub persistence: PersistenceSection, + #[serde(default)] + pub test: TestSection, + #[serde(default)] + pub menu: MenuSection, +} + +#[derive(Debug, Clone, Deserialize)] +struct PackageSection { + pub format: String, + pub kind: String, + pub id: String, + pub name: String, + pub version: String, + #[serde(default)] + pub requires_key: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct LegalSection { + pub status: String, + #[serde(default)] + pub requires_key: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct SourceSection { + #[serde(default)] + pub url: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct DoorSection { + pub id: String, + pub name: String, + #[serde(default)] + pub category: Option, + pub runner: String, + pub command: String, + #[serde(default, alias = "working_directory")] + pub working_directory: Option, + #[serde(default, alias = "working_dir")] + pub working_dir: Option, + #[serde(default, alias = "preferred_dropfile")] + #[serde(alias = "preferred_drop_file")] + pub preferred_drop_file: Option, + #[serde(default, alias = "supported_dropfiles")] + #[serde(alias = "supported_drop_files")] + pub supported_drop_files: Vec, + #[serde(default)] + pub exclusive: Option, + #[serde(default)] + pub timeout_seconds: Option, + #[serde(default, alias = "enabled_after_import")] + pub enabled_after_import: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct AccessSection { + #[serde(default)] + pub min_security_level: i32, + #[serde(default)] + pub preferred_drop_file: Option, + #[serde(default)] + pub supported_drop_files: Vec, + #[serde(default)] + pub exclusive: Option, + #[serde(default)] + pub timeout_seconds: Option, +} + +impl Default for AccessSection { + fn default() -> Self { + Self { + min_security_level: 0, + preferred_drop_file: None, + supported_drop_files: Vec::new(), + exclusive: None, + timeout_seconds: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PersistenceSection { + #[serde(default)] + pub enabled_after_import_request: Option, + #[serde(default)] + pub timeout_seconds: Option, +} + +impl Default for PersistenceSection { + fn default() -> Self { + Self { + enabled_after_import_request: None, + timeout_seconds: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct TestSection { + #[serde(default)] + pub timeout_seconds: Option, +} + +impl Default for TestSection { + fn default() -> Self { + Self { + timeout_seconds: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct MenuSection { + #[serde(default)] + pub category: Option, + #[serde(default)] + pub exclusive: Option, + #[serde(default)] + pub timeout_seconds: Option, +} + +impl Default for MenuSection { + fn default() -> Self { + Self { + category: None, + exclusive: None, + timeout_seconds: None, + } + } +} + +impl DoorSection { + fn working_directory(&self) -> Option<&str> { + self.working_directory + .as_deref() + .or(self.working_dir.as_deref()) + } +} + +impl DoorSection { + fn supported_drop_files(&self) -> Vec { + self.supported_drop_files.clone() + } +} + +pub fn inspect_oxide_door_package( + package_path: impl AsRef, +) -> Result { + let package_path = package_path.as_ref(); + let file = File::open(package_path).map_err(|source| DoorError::ReadDoorPackage { + path: package_path.to_path_buf(), + source, + })?; + + let mut archive = ZipArchive::new(file).map_err(|source| DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!("not a valid ZIP archive: {source}"), + })?; + + let manifest = read_manifest(&mut archive, package_path)?; + let mut summary = validate_manifest(package_path, &manifest)?; + let checksums = read_checksums(&mut archive, package_path)?; + let (file_count, total_unpacked_size) = verify_files(package_path, &mut archive, &checksums)?; + + summary.file_count = file_count; + summary.total_unpacked_size = total_unpacked_size; + + Ok(summary) +} + +fn read_manifest( + archive: &mut ZipArchive, + package_path: &Path, +) -> Result { + let contents = read_text_entry(archive, package_path, OXDOOR_MANIFEST_FILE)?; + toml::from_str(&contents).map_err(|source| DoorError::ParseDoorPackage { + path: package_path.to_path_buf(), + source, + }) +} + +fn read_checksums( + archive: &mut ZipArchive, + package_path: &Path, +) -> Result, DoorError> { + let contents = read_text_entry(archive, package_path, OXDOOR_CHECKSUM_FILE)?; + parse_checksums(package_path, &contents) +} + +fn parse_checksums( + package_path: &Path, + contents: &str, +) -> Result, DoorError> { + let mut checksums = HashMap::new(); + + for (line_no, raw_line) in contents.lines().enumerate() { + let line = raw_line.trim(); + if line.is_empty() { + continue; + } + let mut parts = line.split_whitespace(); + let checksum = parts.next().unwrap_or_default(); + let candidate_path = parts.next().unwrap_or_default(); + if checksum.is_empty() || candidate_path.is_empty() || parts.next().is_some() { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!( + "invalid checksum line {}: expected ' '", + line_no + 1 + ), + }); + } + if checksum.len() != 64 || hex::decode(checksum).is_err() { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!("invalid SHA-256 checksum on line {}", line_no + 1), + }); + } + let normalized_path = normalize_and_validate_checksum_path(package_path, candidate_path)?; + let exists = checksums.insert(normalized_path.clone(), checksum.to_ascii_lowercase()); + if exists.is_some() { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!("duplicate checksum entry for {candidate_path}"), + }); + } + } + + if checksums.is_empty() { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!("{} is empty", OXDOOR_CHECKSUM_FILE), + }); + } + + Ok(checksums) +} + +fn validate_manifest( + package_path: &Path, + manifest: &RawDoorPackageManifest, +) -> Result { + if manifest.package.format != OXDOOR_PACKAGE_FORMAT { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!( + "unsupported package format {:?}; expected {:?}", + manifest.package.format, OXDOOR_PACKAGE_FORMAT + ), + }); + } + if manifest.package.kind.to_ascii_lowercase() != OXDOOR_PACKAGE_KIND_FULL { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: if manifest.package.kind.trim().is_empty() { + "package.kind is required".to_string() + } else { + format!( + "unsupported package kind {:?}; only \"{}\" is supported now", + manifest.package.kind, OXDOOR_PACKAGE_KIND_FULL + ) + }, + }); + } + + let package_name = trim_required(&manifest.package.name) + .ok_or_else(|| DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: "package.name is required".to_string(), + })?; + let package_id = trim_required(&manifest.package.id).ok_or_else(|| { + DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: "package.id is required".to_string(), + } + })?; + let package_version = trim_required(&manifest.package.version).ok_or_else(|| { + DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: "package.version is required".to_string(), + } + })?; + validate_id_characters(package_path, "package.id", &package_id)?; + + let legal_status = trim_required(&manifest.legal.status).ok_or_else(|| { + DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: "legal.status is required".to_string(), + } + })?; + let requires_key = manifest + .legal + .requires_key + .or(manifest.package.requires_key) + .unwrap_or(false); + + let door_id = trim_required(&manifest.door.id).ok_or_else(|| { + DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: "door.id is required".to_string(), + } + })?; + let door_name = trim_required(&manifest.door.name).ok_or_else(|| { + DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: "door.name is required".to_string(), + } + })?; + validate_id_characters(package_path, "door.id", &door_id)?; + + if manifest.door.runner.trim().is_empty() { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: "door.runner is required".to_string(), + }); + } + if !manifest.door.runner.trim().eq_ignore_ascii_case("local:dosemu2") { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: "unsupported runner; v1 packages must set door.runner = \"local:dosemu2\"" + .to_string(), + }); + } + + let command = trim_required(&manifest.door.command).ok_or_else(|| { + DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: "door.command is required".to_string(), + } + })?; + + let working_directory = manifest + .door + .working_directory() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: "door.working_directory is required".to_string(), + })?; + validate_relative_directory(package_path, "door.working_directory", working_directory)?; + + let preferred_drop_file = manifest + .door + .preferred_drop_file + .as_ref() + .or(manifest.access.preferred_drop_file.as_ref()) + .map(|value| normalize_drop_file(package_path, value)) + .transpose()? + .or_else(|| { + manifest.access + .supported_drop_files + .first() + .map(|value| normalize_drop_file(package_path, value)) + .transpose() + .ok() + .flatten() + }) + .ok_or_else(|| { + DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: "preferred drop-file format is required".to_string(), + } + })?; + + let mut supported_drop_files = Vec::new(); + for value in manifest + .door + .supported_drop_files() + .into_iter() + .chain(manifest.access.supported_drop_files.iter().cloned()) + { + let normalized = normalize_drop_file(package_path, &value)?; + supported_drop_files.push(normalized); + } + supported_drop_files.sort(); + supported_drop_files.dedup(); + if manifest.door.supported_drop_files.is_empty() && manifest.access.supported_drop_files.is_empty() { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: "door-support section requires supported drop-file formats".to_string(), + }); + } + if !supported_drop_files.iter().any(|value| value == &preferred_drop_file) { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!( + "preferred drop-file format {:?} is not listed in supported_drop_files", + preferred_drop_file + ), + }); + } + + let timeout_seconds = manifest + .door + .timeout_seconds + .or(manifest.access.timeout_seconds) + .or(manifest.persistence.timeout_seconds) + .or(manifest.test.timeout_seconds) + .or(manifest.menu.timeout_seconds) + .unwrap_or(30 * 60); + if timeout_seconds == 0 { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: "timeout_seconds must be greater than 0".to_string(), + }); + } + + let min_security_level = manifest.access.min_security_level; + if min_security_level < 0 { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: "access.min_security_level must be >= 0".to_string(), + }); + } + + let enabled_after_import_request = manifest + .door + .enabled_after_import + .or(manifest.persistence.enabled_after_import_request) + .unwrap_or(false); + + let mut warnings = Vec::new(); + if manifest + .source + .as_ref() + .and_then(|source| source.url.as_ref()) + .is_none() + { + warnings.push("source.url is not present".to_string()); + } + Ok(OxDoorPackageSummary { + package_name, + package_id, + package_version, + package_kind: manifest.package.kind.trim().to_string(), + legal_status, + requires_key, + source_url: manifest + .source + .as_ref() + .and_then(|source| source.url.as_ref()) + .map(|url| url.trim().to_string()), + door_id, + door_name, + door_category: manifest + .menu + .category + .clone() + .or_else(|| manifest.door.category.clone()) + .unwrap_or_else(|| "uncategorized".to_string()), + runner: manifest.door.runner.trim().to_string(), + command, + working_directory: working_directory.to_string(), + preferred_drop_file, + supported_drop_files, + exclusive: manifest + .menu + .exclusive + .or(manifest.door.exclusive) + .or(manifest.access.exclusive) + .unwrap_or(false), + timeout_seconds, + min_security_level, + enabled_after_import_request, + file_count: 0, + total_unpacked_size: 0, + warnings, + }) +} + +fn verify_files( + package_path: &Path, + archive: &mut ZipArchive, + checksums: &HashMap, +) -> Result<(usize, u64), DoorError> { + let mut file_count = 0usize; + let mut total_unpacked_size = 0u64; + let mut verified = HashSet::new(); + + for index in 0..archive.len() { + let mut entry = archive.by_index(index).map_err(|source| DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!("invalid archive entry at index {index}: {source}"), + })?; + let entry_name = entry.name().to_string(); + validate_entry_name(package_path, &entry_name)?; + validate_entry_mode(package_path, &entry_name, &entry)?; + + if entry_name == OXDOOR_MANIFEST_FILE || entry_name == OXDOOR_CHECKSUM_FILE { + continue; + } + if entry.is_dir() || entry_name.ends_with('/') { + continue; + } + if !entry_name.starts_with(FILES_DIRECTORY) { + continue; + } + let entry_relative = entry_name.trim_start_matches(FILES_DIRECTORY); + if entry_relative.is_empty() { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: "files/ entry path is invalid".to_string(), + }); + } + let expected = checksums + .get(&entry_name) + .or_else(|| checksums.get(&format!("./{entry_name}"))) + .or_else(|| checksums.get(entry_relative)) + .or_else(|| checksums.get(&format!("./{entry_relative}"))); + let expected = expected.ok_or_else(|| { + DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!("missing checksum for file {entry_relative:?}"), + } + })?; + verify_file_checksum(package_path, &mut entry, expected)?; + verified.insert(format!("files/{entry_relative}")); + file_count += 1; + total_unpacked_size = total_unpacked_size.saturating_add(entry.size()); + } + + for expected_path in checksums.keys() { + let normalized = normalize_for_lookup(expected_path); + if normalized.starts_with(FILES_DIRECTORY) && !verified.contains(&normalized) { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!("checksum entry has no matching files/ payload: {expected_path}"), + }); + } + } + + if file_count == 0 { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: "package.files directory must contain at least one regular file".to_string(), + }); + } + + Ok((file_count, total_unpacked_size)) +} + +fn read_text_entry( + archive: &mut ZipArchive, + package_path: &Path, + entry_name: &str, +) -> Result { + let mut entry = archive.by_name(entry_name).map_err(|_| DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!("missing required entry: {entry_name}"), + })?; + let mut text = String::new(); + entry.read_to_string(&mut text).map_err(|source| { + DoorError::ReadDoorPackage { + path: package_path.to_path_buf(), + source, + } + })?; + Ok(text) +} + +fn normalize_for_lookup(value: &str) -> String { + value + .trim_start_matches("./") + .trim_end_matches('/') + .to_string() +} + +fn normalize_and_validate_checksum_path( + package_path: &Path, + value: &str, +) -> Result { + if value.contains('\\') { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!("invalid checksum path {value:?}: backslashes are not allowed"), + }); + } + let normalized = normalize_for_lookup(value); + validate_entry_name(package_path, &normalized)?; + if normalized.is_empty() { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: "empty checksum path".to_string(), + }); + } + Ok(normalized) +} + +fn normalize_drop_file(package_path: &Path, value: &str) -> Result { + let normalized = value.trim().to_ascii_uppercase(); + if normalized.is_empty() { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: "drop-file format must not be blank".to_string(), + }); + } + if !OXDOOR_SUPPORTED_DROPFILES.contains(&normalized.as_str()) { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!("unsupported drop-file format: {value}"), + }); + } + Ok(normalized) +} + +fn validate_entry_name(package_path: &Path, entry_name: &str) -> Result<(), DoorError> { + if entry_name.contains('\\') { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!("invalid path {entry_name:?}: backslashes are not allowed"), + }); + } + if entry_name.starts_with('/') { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!("invalid path {entry_name:?}: absolute paths are not allowed"), + }); + } + if is_windows_drive_path(entry_name) { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!( + "invalid path {entry_name:?}: windows drive-style paths are not allowed" + ), + }); + } + + for component in Path::new(entry_name).components() { + match component { + Component::ParentDir | Component::CurDir | Component::RootDir => { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!("invalid path {entry_name:?}: traversal is not allowed"), + }); + } + Component::Normal(_) | Component::Prefix(_) => {} + } + } + + Ok(()) +} + +fn validate_relative_directory( + package_path: &Path, + field_name: &str, + value: &str, +) -> Result<(), DoorError> { + if value.starts_with('/') { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!("{field_name} must be relative, got {value:?}"), + }); + } + if value.contains('\\') || value.contains("..") || is_windows_drive_path(value) { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!("{field_name} contains unsupported path characters: {value:?}"), + }); + } + Ok(()) +} + +fn validate_entry_mode( + package_path: &Path, + entry_name: &str, + entry: &zip::read::ZipFile<'_, File>, +) -> Result<(), DoorError> { + let Some(mode) = entry.unix_mode() else { + return Ok(()); + }; + let kind = mode & 0o170000; + if kind == 0o120000 { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!("archive entry {entry_name:?} is a symlink"), + }); + } + if kind != 0o100000 && kind != 0o040000 { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!( + "archive entry {entry_name:?} is unsupported file type (mode {mode:o})" + ), + }); + } + Ok(()) +} + +fn verify_file_checksum( + package_path: &Path, + entry: &mut zip::read::ZipFile<'_, File>, + expected_hex: &str, +) -> Result<(), DoorError> { + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 8192]; + loop { + let count = entry.read(&mut buffer).map_err(|source| { + DoorError::ReadDoorPackage { + path: package_path.to_path_buf(), + source, + } + })?; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + } + let actual = hex::encode(hasher.finalize()); + if actual != expected_hex.to_ascii_lowercase() { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!( + "checksum mismatch for {:?}: expected {expected_hex}, got {actual}", + entry.name() + ), + }); + } + Ok(()) +} + +fn is_windows_drive_path(value: &str) -> bool { + let mut chars = value.chars(); + matches!((chars.next(), chars.next()), (Some(drive), Some(':')) if drive.is_ascii_alphabetic()) +} + +fn validate_id_characters(package_path: &Path, field: &str, value: &str) -> Result<(), DoorError> { + if value.chars().any(|ch| ch == '/' || ch == '\\' || ch == ':') { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!("{field} must not contain path separators"), + }); + } + Ok(()) +} + +fn trim_required(value: &str) -> Option { + let value = value.trim().to_string(); + if value.is_empty() { + None + } else { + Some(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::io; + use std::time::{SystemTime, UNIX_EPOCH}; + use zip::write::{FileOptions, SimpleFileOptions}; + use zip::ZipWriter; + + use crate::OXDOOR_PACKAGE_FORMAT; + + fn build_manifest(override_runner: &str, preferred_drop_file: &str, kind: &str) -> String { + format!( + r#" +[package] +format = "{OXDOOR_PACKAGE_FORMAT}" +kind = "{kind}" +id = "sample-package" +name = "Sample Package" +version = "1.0.0" +requires_key = false + +[legal] +status = "freeware" +requires_key = false + +[source] +url = "https://example.invalid/sample" + +[door] +id = "sample-door" +name = "Sample Door" +runner = "{override_runner}" +command = "START.BAT" +working_directory = "sample" +category = "game" +preferred_drop_file = "{preferred_drop_file}" +supported_drop_files = ["DOOR.SYS", "DORINFO1.DEF", "CHAIN.TXT"] +exclusive = true +timeout_seconds = 120 +enabled_after_import = true + +[access] +min_security_level = 10 +preferred_drop_file = "{preferred_drop_file}" +supported_drop_files = ["DOOR.SYS", "DORINFO1.DEF", "CHAIN.TXT"] +exclusive = true +timeout_seconds = 120 + +[persistence] +enabled_after_import_request = true + +[test] +timeout_seconds = 120 + +[menu] +category = "doors" +exclusive = true +timeout_seconds = 120 +"# + ) + } + + fn write_fixture( + path: &Path, + manifest: &str, + files: &[(&str, &[u8])], + include_checksums: bool, + include_traversal: bool, + checksum_overrides: Option<&HashMap>, + ) -> io::Result<()> { + let file = File::create(path)?; + let mut writer = ZipWriter::new(file); + let options: FileOptions<'_, ()> = + SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + + writer.start_file(OXDOOR_MANIFEST_FILE, options)?; + writer.write_all(manifest.as_bytes())?; + + let mut checksums = HashMap::new(); + for (name, bytes) in files { + writer.start_file(format!("{FILES_DIRECTORY}{name}"), options)?; + writer.write_all(bytes)?; + checksums.insert( + format!("{FILES_DIRECTORY}{name}"), + hex::encode(Sha256::digest(bytes)), + ); + } + + if include_traversal { + let name = "files/../outside.bin"; + writer.start_file(name, options)?; + writer.write_all(b"bad")?; + checksums.insert(name.to_string(), hex::encode(Sha256::digest(b"bad"))); + } + + if include_checksums { + let mut entries = checksums; + if let Some(overrides) = checksum_overrides { + for (name, digest) in overrides { + entries.insert(name.clone(), digest.clone()); + } + } + let checksum_contents = entries + .into_iter() + .map(|(name, digest)| format!("{digest} {name}\n")) + .collect::(); + writer.start_file(OXDOOR_CHECKSUM_FILE, options)?; + writer.write_all(checksum_contents.as_bytes())?; + } + + writer.finish()?; + Ok(()) + } + + fn temp_dir() -> PathBuf { + let path = std::env::temp_dir().join(format!( + "oxidebbs-oxdoor-test-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("timestamp") + .as_nanos() + )); + std::fs::create_dir_all(&path).expect("create temp dir"); + path + } + + fn cleanup(path: &Path) { + let _ = std::fs::remove_dir_all(path); + } + + #[test] + fn inspect_valid_oxide_door_package() { + let temp = temp_dir(); + let package_path = temp.join("valid.oxdoor"); + + write_fixture( + &package_path, + &build_manifest("local:dosemu2", "DOOR.SYS", "full"), + &[("readme.txt", b"hello\n"), ("readme2.txt", b"world\n")], + true, + false, + None, + ) + .expect("write fixture"); + + let summary = inspect_oxide_door_package(&package_path).expect("inspect"); + assert_eq!(summary.package_name, "Sample Package"); + assert_eq!(summary.package_id, "sample-package"); + assert_eq!(summary.legal_status, "freeware"); + assert_eq!(summary.door_category, "doors"); + assert_eq!(summary.runner, "local:dosemu2"); + assert_eq!(summary.timeout_seconds, 120); + assert_eq!(summary.file_count, 2); + assert_eq!(summary.total_unpacked_size, 12); + assert_eq!(summary.preferred_drop_file, "DOOR.SYS"); + assert_eq!(summary.supported_drop_files, vec!["CHAIN.TXT","DORINFO1.DEF","DOOR.SYS"]); + cleanup(&temp); + } + + #[test] + fn inspect_package_requires_manifest() { + let temp = temp_dir(); + let package_path = temp.join("missing-manifest.oxdoor"); + write_fixture( + &package_path, + &build_manifest("local:dosemu2", "DOOR.SYS", "full"), + &[], + true, + false, + None, + ) + .expect("write"); + { + let file = File::create(&package_path).expect("open"); + let mut writer = ZipWriter::new(file); + let options: FileOptions<'_, ()> = + SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + writer.start_file(OXDOOR_CHECKSUM_FILE, options).unwrap(); + writer + .write_all(b"e3b0c44298fc1c149afb... files/readme.txt\n") + .unwrap(); + writer.finish().unwrap(); + } + let error = inspect_oxide_door_package(&package_path).expect_err("missing manifest"); + assert!(error.to_string().contains("missing required entry")); + cleanup(&temp); + } + + #[test] + fn inspect_package_requires_checksums() { + let temp = temp_dir(); + let package_path = temp.join("missing-checksums.oxdoor"); + write_fixture( + &package_path, + &build_manifest("local:dosemu2", "DOOR.SYS", "full"), + &[("readme.txt", b"hello")], + false, + false, + None, + ) + .expect("write"); + let error = inspect_oxide_door_package(&package_path).expect_err("missing checksums"); + assert!(error.to_string().contains("missing required entry")); + cleanup(&temp); + } + + #[test] + fn inspect_package_rejects_invalid_kind() { + let temp = temp_dir(); + let package_path = temp.join("bad-kind.oxdoor"); + write_fixture( + &package_path, + &build_manifest("local:dosemu2", "DOOR.SYS", "recipe"), + &[("readme.txt", b"hello")], + true, + false, + None, + ) + .expect("write"); + let error = inspect_oxide_door_package(&package_path).expect_err("bad kind"); + assert!(error.to_string().contains("unsupported package kind")); + cleanup(&temp); + } + + #[test] + fn inspect_package_rejects_unsupported_runner() { + let temp = temp_dir(); + let package_path = temp.join("bad-runner.oxdoor"); + write_fixture( + &package_path, + &build_manifest("remote:doorparty", "DOOR.SYS", "full"), + &[("readme.txt", b"hello")], + true, + false, + None, + ) + .expect("write"); + let error = inspect_oxide_door_package(&package_path).expect_err("bad runner"); + assert!(error.to_string().contains("unsupported runner")); + cleanup(&temp); + } + + #[test] + fn inspect_package_rejects_unsupported_drop_file() { + let temp = temp_dir(); + let package_path = temp.join("bad-drop.oxdoor"); + write_fixture( + &package_path, + &build_manifest("local:dosemu2", "BAD.DROP", "full"), + &[("readme.txt", b"hello")], + true, + false, + None, + ) + .expect("write"); + let error = inspect_oxide_door_package(&package_path).expect_err("bad drop file"); + assert!(error.to_string().contains("unsupported drop-file format")); + cleanup(&temp); + } + + #[test] + fn inspect_package_rejects_checksum_mismatch() { + let temp = temp_dir(); + let package_path = temp.join("bad-checksum.oxdoor"); + let overrides = + HashMap::from([("files/readme.txt".to_string(), "ff".repeat(32) + "11")]); + write_fixture( + &package_path, + &build_manifest("local:dosemu2", "DOOR.SYS", "full"), + &[("readme.txt", b"hello")], + true, + false, + Some(&overrides), + ) + .expect("write"); + let error = inspect_oxide_door_package(&package_path).expect_err("checksum mismatch"); + assert!(error.to_string().contains("checksum mismatch")); + cleanup(&temp); + } + + #[test] + fn inspect_package_rejects_path_traversal_in_files() { + let temp = temp_dir(); + let package_path = temp.join("bad-path.oxdoor"); + write_fixture( + &package_path, + &build_manifest("local:dosemu2", "DOOR.SYS", "full"), + &[("readme.txt", b"hello")], + true, + true, + None, + ) + .expect("write"); + let error = inspect_oxide_door_package(&package_path).expect_err("path traversal"); + assert!( + error.to_string().contains("traversal") || error.to_string().contains("not allowed") + ); + cleanup(&temp); + } +} diff --git a/crates/oxidebbs-server/src/commands/doors.rs b/crates/oxidebbs-server/src/commands/doors.rs index a1a321c..b9fb970 100644 --- a/crates/oxidebbs-server/src/commands/doors.rs +++ b/crates/oxidebbs-server/src/commands/doors.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::fs; #[cfg(unix)] use std::os::unix::fs::MetadataExt; -use std::path::Path; +use std::path::{Path, PathBuf}; use clap::{Args, Subcommand}; #[cfg(unix)] @@ -73,6 +73,10 @@ pub enum DoorsCommand { }, Test(DoorTestArgs), Dropfile(DoorDropfileArgs), + Package { + #[command(subcommand)] + command: DoorPackageCommand, + }, Add(DoorAddArgs), Edit(DoorEditArgs), Runs { @@ -106,6 +110,13 @@ pub struct DoorDropfileArgs { pub output: Option, } +#[derive(Debug, Clone, Subcommand)] +pub enum DoorPackageCommand { + Inspect { + path: PathBuf, + }, +} + #[derive(Debug, Clone, Args)] pub struct DoorAddArgs { pub key: String, @@ -198,6 +209,55 @@ fn print_check_issues(issues: &[CheckIssue]) { } } +fn run_door_package(args: DoorPackageCommand, ctx: &AppContext) -> CliResult<()> { + let summary = oxidebbs_door::inspect_oxide_door_package(&args.path)?; + if ctx.json { + print_json(&summary)?; + } else { + println!("package.name: {}", summary.package_name); + println!("package.id: {}", summary.package_id); + println!("package.version: {}", summary.package_version); + println!("package.kind: {}", summary.package_kind); + println!("package.legal_status: {}", summary.legal_status); + println!("package.requires_key: {}", if summary.requires_key { "yes" } else { "no" }); + if let Some(source_url) = summary.source_url.as_deref() { + println!("package.source_url: {}", source_url); + } + println!("door.id: {}", summary.door_id); + println!("door.name: {}", summary.door_name); + println!("door.category: {}", summary.door_category); + println!("door.runner: {}", summary.runner); + println!("door.command: {}", summary.command); + println!("door.working_directory: {}", summary.working_directory); + println!( + "door.preferred_drop_file: {}", + summary.preferred_drop_file + ); + println!( + "door.supported_drop_files: {}", + summary.supported_drop_files.join(", ") + ); + println!("door.exclusive: {}", if summary.exclusive { "yes" } else { "no" }); + println!("door.timeout_seconds: {}", summary.timeout_seconds); + println!("access.min_security_level: {}", summary.min_security_level); + println!( + "persistence.enabled_after_import_request: {}", + if summary.enabled_after_import_request { "yes" } else { "no" } + ); + println!("files.count: {}", summary.file_count); + println!("files.total_unpacked_size: {}", summary.total_unpacked_size); + if summary.warnings.is_empty() { + println!("warnings: (none)"); + } else { + println!("warnings:"); + for warning in summary.warnings { + println!(" - {warning}"); + } + } + } + Ok(()) +} + fn command_exists(command: &str) -> bool { let path = std::path::Path::new(command); if path.components().count() > 1 { @@ -218,8 +278,21 @@ fn is_quoted_dos_command(command: &str) -> bool { } pub fn run_doors(command: DoorsCommand, ctx: &AppContext) -> CliResult<()> { - let db = open_database(&ctx.config)?; - sync_configured_doors(&db, &ctx.config)?; + match command { + DoorsCommand::Package { command } => run_door_package(command, ctx), + command => { + let db = open_database(&ctx.config)?; + sync_configured_doors(&db, &ctx.config)?; + run_doors_with_db(command, ctx, db) + } + } +} + +fn run_doors_with_db( + command: DoorsCommand, + ctx: &AppContext, + db: oxidebbs_db::OxideDb, +) -> CliResult<()> { match command { DoorsCommand::List => { let doors = effective_doors(&db, &ctx.config)?; diff --git a/design/OXDOOR_FORMAT_V1.md b/design/OXDOOR_FORMAT_V1.md new file mode 100644 index 0000000..ecd5109 --- /dev/null +++ b/design/OXDOOR_FORMAT_V1.md @@ -0,0 +1,542 @@ +# Oxide Door Package Format v1 (`.oxdoor`) + +Status: Draft v1 +Audience: OxideBBS maintainers, door package authors, and sysops +Primary producer: `oxidebbs-door-lab` +Primary consumer: `oxidebbs-server doors package ...` + +## Quick format summary + +An `.oxdoor` file is a ZIP archive with UTF-8 text metadata and file payload sections: + +```text +oxide-door.toml +checksums.sha256 +files/ +docs/ optional +artifacts/ optional +``` + +`oxide-door.toml` must include: +- `package.format = "oxide-door-package-v1"` +- `package.kind = "full"` + +All files under `files/`, `docs/`, and `artifacts/` should be listed in `checksums.sha256` using: + +```text + +``` + +## 1. Purpose + +An Oxide Door Package (`.oxdoor`) is a portable, declarative package for installing a DOS BBS door into an OxideBBS system. + +The goal is to make door setup repeatable without asking sysops to manually copy archives, guess launch commands, hand-edit door records, or re-discover drop-file requirements every time a door is installed. + +A v1 full package may contain: + +- door metadata +- source and legal metadata +- extracted door files +- file checksums +- OxideBBS runtime settings +- drop-file preferences +- persistence rules +- test hints +- menu/category hints + +A v1 package must not contain arbitrary installer hooks or scripts that OxideBBS executes automatically. + +## 2. Design principles + +### 2.1 Declarative, not executable + +The package describes what should be installed. It must not run installer scripts, key generators, shell scripts, batch files, or downloaded commands as part of import. + +OxideBBS may copy files, create or update door definitions through its own internal services, validate package contents, generate drop files, and run supported dry-run checks. It must not execute package-provided installer code during import. + +### 2.2 Safe by default + +Imported doors should default to disabled unless the sysop explicitly passes an enable flag. A package import should not make a new third-party binary immediately caller-accessible by default. + +### 2.3 No hidden writes + +`--dry-run` import must perform validation and print planned changes without writing files, modifying DecentDB, enabling doors, or changing menus. + +### 2.4 No path traversal + +Package paths must be relative, normalized, and contained inside the package root. Importers must reject paths that are absolute, contain `..`, contain Windows drive prefixes, or otherwise escape the target install directory. + +### 2.5 Reproducible and inspectable + +Every packaged file under `files/` should appear in `checksums.sha256`. Importers should verify hashes before installation. + +### 2.6 Separate public recipes from private full packages + +A full `.oxdoor` package may contain third-party door binaries when the sysop has the right to store/use them privately. + +A public recipe should not redistribute third-party binaries unless redistribution rights are clear. Public recipes may describe where to fetch a door and what hash to expect, but should avoid bundling copyrighted/shareware/abandonware binaries unless explicitly permitted. + +## 3. File extension and container + +A v1 `.oxdoor` package is a ZIP archive with the file extension `.oxdoor`. + +The ZIP archive must use forward slash path separators. Files should be stored with deterministic relative paths where practical. + +Required top-level entries: + +```text +oxide-door.toml +checksums.sha256 +files/ +``` + +Optional top-level entries: + +```text +docs/ +artifacts/ +``` + +Recommended layout: + +```text +doradvnt.oxdoor +├── oxide-door.toml +├── checksums.sha256 +├── files/ +│ ├── DORADVNT.EXE +│ ├── DORADVNT.DOC +│ └── ... +├── docs/ +│ ├── README.TXT +│ └── SYSOP.DOC +└── artifacts/ + └── inspect-report.md +``` + +## 4. Package kinds + +v1 recognizes two package kinds: + +```toml +kind = "full" +``` + +A full package contains the actual door files under `files/`. + +```toml +kind = "recipe" +``` + +A recipe package contains metadata, source URLs, expected archive hashes, and setup instructions, but does not include third-party binaries under `files/`. Recipe support may be implemented after full package support. + +For the first implementation, OxideBBS may support only `kind = "full"` and reject `recipe` with a clear message. + +## 5. `oxide-door.toml` + +`oxide-door.toml` is the package manifest. It must be UTF-8 TOML. + +### 5.1 Minimal example + +```toml +[package] +format = "oxide-door-package-v1" +kind = "full" +id = "doradvnt" +name = "Door Adventure" +version = "unknown" +created_by = "oxidebbs-door-lab" +created_at = "2026-06-07T00:00:00Z" + +[legal] +status = "freeware_confirmed" +requires_key = false +redistributable = "operator_asserted" +notes = "User identified this package source as freeware and no registration key required." + +[source] +name = "Fool's Quarter BBS Files" +url = "https://bbs.foolsquarter.com/files/doradvnt.zip" +archive_filename = "doradvnt.zip" +archive_sha256 = "TO_BE_FILLED_AFTER_DOWNLOAD" + +[door] +id = "doradvnt" +name = "Door Adventure" +description = "Legacy DOS BBS door packaged for OxideBBS." +category = "Adventure" +runner = "local:dosemu2" +working_dir = "doradvnt" +command = "VERIFY_AFTER_INSPECTION" +preferred_dropfile = "DORINFO1.DEF" +supported_dropfiles = ["DORINFO1.DEF", "DOOR.SYS"] +exclusive = false +timeout_seconds = 900 +enabled_after_import = false + +[access] +min_security_level = 10 + +[persistence] +include = ["*.DAT", "*.CFG", "*.SCO", "*.IDX", "*.ANS", "*.TXT", "*.SCR", "*.RNX"] +exclude = ["DOOR.SYS", "DORINFO1.DEF", "CHAIN.TXT", "DOORFILE.SR", "PCBOARD.SYS", "CALLINFO.BBS", "OXNODE.TXT", "OXDOSEMU2.CONF", "OXCOM1.PTY"] + +[test] +dry_run = true +expected_output = [] +quit_sequence = ["Q", "ENTER"] + +[menu] +category = "Adventure" +suggested_key = "A" +suggested_label = "Door Adventure" +``` + +## 6. Manifest fields + +### 6.1 `[package]` + +Required fields: + +- `format`: must be `"oxide-door-package-v1"`. +- `kind`: `"full"` or `"recipe"`. +- `id`: package id. Should match `door.id` for simple packages. +- `name`: human-readable package name. +- `version`: package version, upstream door version, or `"unknown"`. +- `created_by`: tool or person that created the package. +- `created_at`: UTC timestamp in RFC 3339 format when possible. + +Rules: + +- `id` must be lowercase ASCII using only `a-z`, `0-9`, and `-`. +- `id` should be stable over time. +- `id` must not contain path separators. + +### 6.2 `[legal]` + +Required fields: + +- `status` +- `requires_key` +- `redistributable` + +Suggested `status` values: + +- `freeware_confirmed` +- `author_released` +- `public_domain` +- `shareware_unregistered` +- `operator_provided` +- `unknown` +- `legal_hold` + +Suggested `redistributable` values: + +- `yes` +- `no` +- `unknown` +- `operator_asserted` + +Rules: + +- `legal_hold` packages must not be imported unless a sysop passes a future explicit override flag. +- `requires_key = true` is allowed, but v1 import must not run key generators. +- Key generators must never be executed automatically by package import. + +### 6.3 `[source]` + +Recommended fields: + +- `name` +- `url` +- `archive_filename` +- `archive_sha256` +- `retrieved_at` +- `notes` + +Rules: + +- `archive_sha256` should be present for packages created from a known archive. +- If hash verification was not possible, use a clear placeholder and make the package builder fail unless an explicit `--allow-missing-source-hash` flag is supplied. + +### 6.4 `[door]` + +Required fields: + +- `id` +- `name` +- `runner` +- `working_dir` +- `command` +- `preferred_dropfile` +- `supported_dropfiles` +- `exclusive` +- `timeout_seconds` +- `enabled_after_import` + +Suggested fields: + +- `description` +- `category` +- `environment` +- `notes` + +Rules: + +- `door.id` must use lowercase ASCII `a-z`, `0-9`, and `-`. +- `runner` v1 should support `local:dosemu2` first. +- `command` is the command OxideBBS should run from the installed door working directory. +- `working_dir` is relative to the configured OxideBBS door root unless OxideBBS explicitly supports another safe mapping. +- `enabled_after_import` should default to `false`. + +Supported v1 drop-file values should match OxideBBS-supported formats: + +- `DOOR.SYS` +- `DORINFO1.DEF` +- `CHAIN.TXT` +- `DOORFILE.SR` +- `PCBOARD.SYS` +- `CALLINFO.BBS` + +### 6.5 `[access]` + +Recommended fields: + +- `min_security_level` + +Rules: + +- If omitted, OxideBBS should use its existing default door access behavior. +- Importers should reject negative security levels. + +### 6.6 `[persistence]` + +Recommended fields: + +- `include` +- `exclude` + +Purpose: + +This section tells OxideBBS which door-owned files are expected to persist between per-node runtime sessions. + +Rules: + +- Generated drop files and OxideBBS runtime bridge files should be excluded. +- Patterns are advisory for v1 if OxideBBS already has built-in persistence behavior. +- Future versions may make persistence rules stricter. + +### 6.7 `[test]` + +Recommended fields: + +- `dry_run` +- `expected_output` +- `quit_sequence` +- `notes` + +Rules: + +- `dry_run = true` means the package author expects `doors test --dry-run` to be meaningful. +- `expected_output` is intended for future live telnet smoke tests, not for v1 dry-run import. +- `quit_sequence` uses symbolic tokens such as `ENTER`, `ESC`, `CTRL_C`, or printable strings. + +### 6.8 `[menu]` + +Optional fields: + +- `category` +- `suggested_key` +- `suggested_label` + +Rules: + +- v1 package import should treat menu data as hints only. +- v1 import should not rewrite caller menu files automatically. +- Future OxideBBS versions may use this section to assign door categories or generate safe menu entries. + +## 7. `checksums.sha256` + +`checksums.sha256` contains SHA-256 hashes for files packaged under `files/`, `docs/`, and `artifacts/` as appropriate. + +Format: + +```text + +``` + +Example: + +```text +8b2f... files/DORADVNT.EXE +1c94... files/DORADVNT.DOC +``` + +Rules: + +- Paths must be relative to the package root. +- Paths must use forward slashes. +- Importers must reject checksum paths that are absolute or escape the package root. +- For `kind = "full"`, every regular file under `files/` must be listed. +- Importers should reject a full package if `files/` is empty. + +## 8. Import behavior + +### 8.1 Inspect + +Command goal: + +```bash +oxidebbs-server doors package inspect +``` + +Expected behavior: + +- Open the package as ZIP. +- Verify `oxide-door.toml` exists. +- Parse TOML. +- Verify `package.format`. +- Verify `checksums.sha256`. +- Validate required fields. +- Reject unsafe paths. +- Print a human-readable summary. +- Do not write files. +- Do not modify DecentDB. +- Do not enable a door. + +### 8.2 Dry-run import + +Command goal: + +```bash +oxidebbs-server doors package import --dry-run +``` + +Expected behavior: + +- Perform all inspect validations. +- Compute target install directory. +- Detect existing door definition conflicts. +- Detect existing file/directory conflicts. +- Validate runner and drop-file values against OxideBBS-supported behavior. +- Print planned file copies. +- Print planned door definition fields. +- Print planned follow-up validation commands. +- Do not write files. +- Do not modify DecentDB. +- Do not enable a door. + +### 8.3 Real import + +Command goal: + +```bash +oxidebbs-server doors package import +``` + +Expected behavior: + +- Perform all dry-run validations. +- Copy `files/` into the configured door root under the package door working directory. +- Create or update the OxideBBS door definition through existing door service code paths. +- Default to disabled unless an explicit `--enable` flag is provided and `enabled_after_import = true` is honored by policy. +- Run the same validation used by `doors check` when feasible. +- Print suggested next commands. + +Conflict behavior: + +- If a door definition already exists, fail unless `--replace` or a future `--update` flag is provided. +- If a target directory already exists, fail unless `--replace` is provided. +- `--replace` should be conservative and should avoid deleting unknown existing files unless explicitly designed and tested. + +## 9. Security requirements + +Importers must reject: + +- absolute package paths +- `..` path traversal +- Windows drive-prefixed paths such as `C:\...` +- symlinks, hard links, device files, and special files inside the ZIP +- package entries that resolve outside the target door directory +- unsupported package format versions +- unsupported runner values +- unsupported drop-file values +- missing required manifest fields +- checksum mismatches +- `legal.status = "legal_hold"` unless a future explicit override exists + +Importers must not: + +- execute package-provided scripts +- execute key generators +- execute batch files during import +- fetch remote files during v1 full-package import +- rewrite caller menus automatically in v1 +- enable the door by default without explicit sysop intent + +## 10. Door-lab responsibilities + +`oxidebbs-door-lab` should be responsible for: + +- downloading source archives +- hashing source archives +- extracting archives into staging +- inspecting docs and executable names +- producing candidate manifests +- building `.oxdoor` packages +- producing audit reports +- optionally running local dry-run/live smoke tests + +`oxidebbs-door-lab` may generate packages that OxideBBS can import, but it should not be required at runtime by OxideBBS. + +## 11. OxideBBS responsibilities + +OxideBBS should be responsible for: + +- validating `.oxdoor` packages +- safely copying package files into the configured doors root +- creating/updating door definitions in DecentDB through existing internal services +- running existing door checks and dry-run tests +- keeping caller menu routing safe +- defaulting imported third-party doors to disabled + +## 12. Versioning and compatibility + +The v1 package format string is: + +```toml +format = "oxide-door-package-v1" +``` + +Future incompatible changes should use a new format string, such as: + +```toml +format = "oxide-door-package-v2" +``` + +OxideBBS should reject unknown future versions with a clear error. + +## 13. Initial implementation scope + +The first implementation should support: + +- full packages only +- ZIP container only +- TOML manifest only +- local DOSEMU2 doors only +- package inspect +- package import `--dry-run` +- package import disabled by default +- checksum verification +- path safety validation + +Out of scope for v1: + +- arbitrary post-install scripts +- keygen automation +- remote recipe fetching +- automatic menu rewriting +- dependency resolution +- disk-image-backed door installs +- automatic public redistribution decisions diff --git a/docs/OXDOOR_FORMAT_V1.md b/docs/OXDOOR_FORMAT_V1.md new file mode 100644 index 0000000..8e81e9b --- /dev/null +++ b/docs/OXDOOR_FORMAT_V1.md @@ -0,0 +1,20 @@ +# Oxide Door Package Format v1 (`.oxdoor`) + +See the canonical specification in +[`design/OXDOOR_FORMAT_V1.md`](../design/OXDOOR_FORMAT_V1.md). + +In short: + +- `oxide-door.toml` + `checksums.sha256` are required. +- `package.format` must be `oxide-door-package-v1`. +- `package.kind` must be `full` for now. +- Supported payload roots are `files/` (required for full packages), `docs/`, and + `artifacts/` (optional). +- Supported drop files are `DOOR.SYS`, `DORINFO1.DEF`, `CHAIN.TXT`, + `DOORFILE.SR`, `PCBOARD.SYS`, `CALLINFO.BBS`. + +Inspect packages with: + +```bash +oxidebbs-server doors package inspect path/to/package.oxdoor +``` diff --git a/docs/project/doors.md b/docs/project/doors.md index 1838a07..cb54808 100644 --- a/docs/project/doors.md +++ b/docs/project/doors.md @@ -8,7 +8,8 @@ Current capabilities: - Door definitions are stored in DecentDB after setup/config synchronization. - `doors list`, `doors show`, `doors check`, `doors enable`, `doors disable`, - `doors test --dry-run`, `doors add`, `doors edit`, `doors dropfile --format`, + `doors package inspect`, `doors test --dry-run`, `doors add`, `doors edit`, + `doors dropfile --format`, `doors runs list/show`, and `doors cleanup` are available through the sysop CLI. - Drop-file writers cover `DOOR.SYS`, `DORINFO1.DEF`, `CHAIN.TXT`, @@ -95,6 +96,7 @@ remote provider door, `--endpoint` is the value stored as the provider endpoint. Door run history is visible through: ```bash +oxidebbs-server doors package inspect sample.oxdoor oxidebbs-server doors runs list oxidebbs-server doors runs show oxidebbs-server doors cleanup diff --git a/docs/project/sysop-cli.md b/docs/project/sysop-cli.md index fa554b1..c04e8ae 100644 --- a/docs/project/sysop-cli.md +++ b/docs/project/sysop-cli.md @@ -444,6 +444,7 @@ Door management: - `oxidebbs-server doors check` (or `doors check `) - `oxidebbs-server doors enable ` - `oxidebbs-server doors disable ` +- `oxidebbs-server doors package inspect ` - `oxidebbs-server doors test --user sysop --dry-run` - `oxidebbs-server doors dropfile --user sysop --node 1 --format DORINFO1.DEF` - `oxidebbs-server doors runs list` @@ -453,6 +454,8 @@ Door management: Meaning: - `--dry-run` generates drop files and validates input without launching a child. +- `doors package inspect` parses `.oxdoor` packages, validates manifest, checksum, and + file safety constraints, then prints a read-only summary. - `doors dropfile --format` supports `DOOR.SYS`, `DORINFO1.DEF`, `CHAIN.TXT`, `DOORFILE.SR`, `PCBOARD.SYS`, and `CALLINFO.BBS`. - Live interactive DOS door testing requires a caller session. Start `serve`, From 445ed005fadff820e6b23533007f8ab3c4a7c1fe Mon Sep 17 00:00:00 2001 From: Steven Hildreth Date: Sun, 7 Jun 2026 17:36:33 -0500 Subject: [PATCH 2/9] feat(cli): implement dry-run support for door package imports Add a new `import` subcommand to the `doors package` toolset. This command allows sysops to simulate the installation of OXDOOR packages, providing a detailed analysis of target paths, configuration changes, and potential conflicts before any changes are applied. - Add `doors package import --dry-run` to the server CLI - Implement structural validation and installation planning logic - Provide JSON output support for automated tooling - Expand documentation with import usage examples --- Cargo.lock | 1 + crates/oxidebbs-server/Cargo.toml | 1 + crates/oxidebbs-server/src/commands/doors.rs | 754 ++++++++++++++++++- docs/project/doors.md | 1 + docs/project/sysop-cli.md | 4 + 5 files changed, 759 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1186716..8c65a57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1989,6 +1989,7 @@ dependencies = [ "tower-sessions", "tracing", "tracing-subscriber", + "zip", ] [[package]] diff --git a/crates/oxidebbs-server/Cargo.toml b/crates/oxidebbs-server/Cargo.toml index 5dfd82c..30effb9 100644 --- a/crates/oxidebbs-server/Cargo.toml +++ b/crates/oxidebbs-server/Cargo.toml @@ -41,3 +41,4 @@ rcgen = "0.14.8" futures-util = "0.3" tokio-tungstenite = "0.28" tower = "0.5" +zip.workspace = true diff --git a/crates/oxidebbs-server/src/commands/doors.rs b/crates/oxidebbs-server/src/commands/doors.rs index b9fb970..99cb7d0 100644 --- a/crates/oxidebbs-server/src/commands/doors.rs +++ b/crates/oxidebbs-server/src/commands/doors.rs @@ -115,6 +115,11 @@ pub enum DoorPackageCommand { Inspect { path: PathBuf, }, + Import { + path: PathBuf, + #[arg(long)] + dry_run: bool, + }, } #[derive(Debug, Clone, Args)] @@ -210,9 +215,16 @@ fn print_check_issues(issues: &[CheckIssue]) { } fn run_door_package(args: DoorPackageCommand, ctx: &AppContext) -> CliResult<()> { - let summary = oxidebbs_door::inspect_oxide_door_package(&args.path)?; + match args { + DoorPackageCommand::Inspect { path } => run_door_package_inspect(path, ctx), + DoorPackageCommand::Import { path, dry_run } => run_door_package_import(path, dry_run, ctx), + } +} + +fn run_door_package_inspect(path: PathBuf, ctx: &AppContext) -> CliResult<()> { + let summary = oxidebbs_door::inspect_oxide_door_package(&path)?; if ctx.json { - print_json(&summary)?; + print_json(&serde_json::to_value(&summary).map_err(CliError::from)?)?; } else { println!("package.name: {}", summary.package_name); println!("package.id: {}", summary.package_id); @@ -258,6 +270,316 @@ fn run_door_package(args: DoorPackageCommand, ctx: &AppContext) -> CliResult<()> Ok(()) } +#[derive(Debug, Clone, PartialEq)] +struct DoorPackageImportDryRunDefinition { + key: String, + name: String, + runner: String, + working_dir: String, + command: String, + drop_file: String, + exclusive: bool, + time_limit_minutes: u32, + enabled: bool, + min_security_level: i32, +} + +impl DoorPackageImportDryRunDefinition { + fn to_json(&self) -> JsonValue { + json!({ + "key": self.key, + "name": self.name, + "runner": self.runner, + "working_dir": self.working_dir, + "command": self.command, + "drop_file": self.drop_file, + "exclusive": self.exclusive, + "time_limit_minutes": self.time_limit_minutes, + "enabled": self.enabled, + "min_security_level": self.min_security_level + }) + } +} + +#[derive(Debug, Clone)] +struct DoorPackageImportDryRun { + package_path: PathBuf, + package_id: String, + package_name: String, + target_door_id: String, + target_install_directory: PathBuf, + target_install_directory_exists: bool, + door_definition_exists: bool, + file_count: usize, + total_unpacked_size: u64, + will_enable: bool, + would_create_door: DoorPackageImportDryRunDefinition, + warnings: Vec, + blocking_errors: Vec, + follow_up_commands: Vec, +} + +impl DoorPackageImportDryRun { + fn to_json(&self) -> JsonValue { + json!({ + "package_path": self.package_path, + "package_id": self.package_id, + "package_name": self.package_name, + "target_door_id": self.target_door_id, + "target_install_directory": self.target_install_directory, + "target_install_directory_exists": self.target_install_directory_exists, + "door_definition_exists": self.door_definition_exists, + "file_count": self.file_count, + "total_unpacked_size": self.total_unpacked_size, + "will_enable": self.will_enable, + "would_create_door": self.would_create_door.to_json(), + "warnings": self.warnings, + "blocking_errors": self.blocking_errors, + "follow_up_commands": self.follow_up_commands, + }) + } +} + +fn run_door_package_import(path: PathBuf, dry_run: bool, ctx: &AppContext) -> CliResult<()> { + if !dry_run { + return Err(CliError::Message( + "package import is dry-run only in this release; pass --dry-run".to_string(), + )); + } + + let db = open_database(&ctx.config)?; + let report = plan_door_package_import(&db, &ctx.config, &path)?; + if ctx.json { + print_json(&report.to_json())?; + } else { + print_door_package_import_dry_run_report(&report)?; + } + if !report.blocking_errors.is_empty() { + return Err(CliError::Message("package import dry-run has blocking errors".to_string())); + } + Ok(()) +} + +fn print_door_package_import_dry_run_report(report: &DoorPackageImportDryRun) -> CliResult<()> { + if report.blocking_errors.is_empty() { + println!("status: ready"); + } else { + println!("status: blocked"); + } + println!("package.path: {}", report.package_path.display()); + println!("package.id: {}", report.package_id); + println!("package.name: {}", report.package_name); + println!("package.door_id: {}", report.target_door_id); + println!("target.install_directory: {}", report.target_install_directory.display()); + println!( + "target.install_directory_exists: {}", + if report.target_install_directory_exists { "yes" } else { "no" } + ); + println!( + "door.definition_exists: {}", + if report.door_definition_exists { "yes" } else { "no" } + ); + println!("file.count: {}", report.file_count); + println!("file.total_unpacked_size: {}", report.total_unpacked_size); + println!("door.will_enable: {}", if report.will_enable { "yes" } else { "no" }); + println!("door.definition:"); + println!(" key: {}", report.would_create_door.key); + println!(" name: {}", report.would_create_door.name); + println!(" runner: {}", report.would_create_door.runner); + println!(" working_dir: {}", report.would_create_door.working_dir); + println!(" command: {}", report.would_create_door.command); + println!(" drop_file: {}", report.would_create_door.drop_file); + println!(" exclusive: {}", if report.would_create_door.exclusive { "yes" } else { "no" }); + println!( + " time_limit_minutes: {}", + report.would_create_door.time_limit_minutes + ); + println!( + " min_security_level: {}", + report.would_create_door.min_security_level + ); + println!(" enabled: {}", if report.would_create_door.enabled { "yes" } else { "no" }); + if report.warnings.is_empty() { + println!("warnings: (none)"); + } else { + println!("warnings:"); + for warning in &report.warnings { + println!(" - {warning}"); + } + } + if report.blocking_errors.is_empty() { + println!("blocking_errors: (none)"); + } else { + println!("blocking_errors:"); + for issue in &report.blocking_errors { + println!(" - {issue}"); + } + } + println!("follow_up_commands:"); + for command in &report.follow_up_commands { + println!(" - {command}"); + } + Ok(()) +} + +fn plan_door_package_import( + db: &oxidebbs_db::OxideDb, + config: &crate::config::OxideConfig, + package_path: &Path, +) -> CliResult { + let summary = oxidebbs_door::inspect_oxide_door_package(package_path)?; + let mut warnings = summary.warnings.clone(); + let mut blocking_errors = Vec::new(); + + let doors_root = match std::path::Path::new(&config.paths.doors).canonicalize() { + Ok(root) => root, + Err(error) => { + warnings.push(format!( + "doors root {} is not accessible yet: {error}", + config.paths.doors.display() + )); + config.paths.doors.clone() + } + }; + let target_install_directory = doors_root.join(&summary.working_directory); + let target_install_directory_exists = target_install_directory.exists(); + if target_install_directory_exists { + blocking_errors.push(format!( + "target door directory already exists: {}", + target_install_directory.display() + )); + } + + if summary.legal_status.eq_ignore_ascii_case("legal_hold") { + blocking_errors.push("legal status is legal_hold; import is blocked".to_string()); + } + + let runner = if summary.runner.eq_ignore_ascii_case("local:dosemu2") { + "dosemu2".to_string() + } else { + blocking_errors.push("unsupported package runner; only local:dosemu2 is supported".to_string()); + summary.runner.clone() + }; + + if !runner_supports_dosemu2_cli(&runner) { + blocking_errors.push(format!( + "runner {runner:?} is not supported for local package import" + )); + } + if let Err(error) = validate_door_runner(&runner, &config.doors.allowed_runners) { + match error.level { + "error" => blocking_errors.push(error.message), + _ => warnings.push(error.message), + } + } + + if !is_supported_drop_file(&summary.preferred_drop_file) { + blocking_errors.push(format!( + "unsupported preferred drop-file format {:?}", + summary.preferred_drop_file + )); + } else if !summary + .supported_drop_files + .iter() + .any(|format| is_supported_drop_file(format)) + { + blocking_errors.push("no supported drop-file formats".to_string()); + } + + let mut timeout_minutes = (summary.timeout_seconds + 59) / 60; + if summary.timeout_seconds % 60 != 0 { + warnings.push(format!( + "timeout_seconds {} is not aligned to minutes; import will use {} minutes", + summary.timeout_seconds, timeout_minutes + )); + } + if timeout_minutes == 0 { + timeout_minutes = 1; + } + let time_limit_minutes = u32::try_from(timeout_minutes).map_err(|_| { + CliError::Message(format!("invalid timeout_minutes {timeout_minutes} while preparing import")) + })?; + if let Err(error) = validate_door_fields_before_write( + &summary.door_id, + &summary.command, + time_limit_minutes, + &summary.preferred_drop_file, + None, + &summary.working_directory, + ) { + blocking_errors.push(error.to_string()); + } + + let will_enable = false; + if summary.enabled_after_import_request { + warnings.push( + "package requests enabled_after_import but dry-run import defaults to not enabling new doors" + .to_string(), + ); + } + + let existing = find_door_by_key(db.db(), &summary.door_id)?; + let door_definition_exists = existing.is_some(); + if door_definition_exists { + blocking_errors.push(format!( + "door definition {} already exists and would conflict", + summary.door_id + )); + } + + let would_create_door = DoorPackageImportDryRunDefinition { + key: summary.door_id.clone(), + name: summary.door_name.clone(), + runner, + working_dir: summary.working_directory.clone(), + command: summary.command.clone(), + drop_file: summary.preferred_drop_file.clone(), + exclusive: summary.exclusive, + time_limit_minutes, + enabled: will_enable, + min_security_level: summary.min_security_level, + }; + + let follow_up_commands = vec![ + format!( + "oxidebbs-server doors check {}", + summary.door_id + ), + format!( + "oxidebbs-server doors test {} --user sysop --dry-run", + summary.door_id + ), + format!( + "oxidebbs-server doors enable {}", + summary.door_id + ), + ]; + + Ok(DoorPackageImportDryRun { + package_path: package_path.to_path_buf(), + package_id: summary.package_id, + package_name: summary.package_name, + target_door_id: summary.door_id, + target_install_directory, + target_install_directory_exists, + door_definition_exists, + file_count: summary.file_count, + total_unpacked_size: summary.total_unpacked_size, + will_enable, + would_create_door, + warnings, + blocking_errors, + follow_up_commands, + }) +} + +fn is_supported_drop_file(format: &str) -> bool { + matches!( + format.to_ascii_uppercase().as_str(), + "DOOR.SYS" | "DORINFO1.DEF" | "CHAIN.TXT" | "DOORFILE.SR" | "PCBOARD.SYS" | "CALLINFO.BBS" + ) +} + fn command_exists(command: &str) -> bool { let path = std::path::Path::new(command); if path.components().count() > 1 { @@ -1420,8 +1742,436 @@ fn door_run_json(run: &oxidebbs_db::DoorRunRecord) -> JsonValue { #[cfg(test)] mod tests { + use std::collections::HashMap; + use std::io::Write; + use std::time::{SystemTime, UNIX_EPOCH}; + use zip::write::{FileOptions, SimpleFileOptions}; + use zip::ZipWriter; + use super::*; + use oxidebbs_door::{OXDOOR_CHECKSUM_FILE, OXDOOR_MANIFEST_FILE, OXDOOR_PACKAGE_FORMAT}; + use sha2::{Digest, Sha256}; + + const TEST_PACKAGE_PREFIX: &str = "oxidebbs-door-package-import-"; + const TEST_DOOR_FILES_DIR: &str = "files/"; + + fn test_temp_dir() -> PathBuf { + let path = std::env::temp_dir().join(format!( + "{TEST_PACKAGE_PREFIX}{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("timestamp") + .as_nanos() + )); + fs::create_dir_all(&path).expect("create temp dir"); + path + } + + fn cleanup_temp_dir(path: &Path) { + let _ = fs::remove_dir_all(path); + } + + fn build_dry_run_manifest( + legal_status: &str, + kind: &str, + preferred_drop_file: &str, + runner: &str, + ) -> String { + format!( + r#" +[package] +format = "{OXDOOR_PACKAGE_FORMAT}" +kind = "{kind}" +id = "sample-package" +name = "Sample Package" +version = "1.0.0" +requires_key = false + +[legal] +status = "{legal_status}" +requires_key = false + +[source] +url = "https://example.invalid/sample" + +[door] +id = "sample-door" +name = "Sample Door" +runner = "{runner}" +command = "START.BAT" +working_directory = "sample" +category = "game" +preferred_drop_file = "{preferred_drop_file}" +supported_drop_files = ["DOOR.SYS", "DORINFO1.DEF", "CHAIN.TXT"] +exclusive = true +timeout_seconds = 120 +enabled_after_import = true + +[access] +min_security_level = 10 +preferred_drop_file = "{preferred_drop_file}" +supported_drop_files = ["DOOR.SYS", "DORINFO1.DEF", "CHAIN.TXT"] +exclusive = true +timeout_seconds = 120 + +[persistence] +enabled_after_import_request = true + +[test] +timeout_seconds = 120 + +[menu] +category = "doors" +exclusive = true +timeout_seconds = 120 +"# + ) + } + + fn write_dry_run_fixture( + path: &Path, + manifest: &str, + files: &[(&str, &[u8])], + include_checksums: bool, + include_traversal: bool, + checksum_overrides: Option<&HashMap>, + ) -> std::io::Result<()> { + let file = fs::File::create(path)?; + let mut writer = ZipWriter::new(file); + let options: FileOptions<'_, ()> = + SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + + writer.start_file(OXDOOR_MANIFEST_FILE, options)?; + writer.write_all(manifest.as_bytes())?; + + let mut checksums = HashMap::new(); + for (name, bytes) in files { + writer.start_file(format!("{TEST_DOOR_FILES_DIR}{name}"), options)?; + writer.write_all(bytes)?; + checksums.insert( + format!("{TEST_DOOR_FILES_DIR}{name}"), + hex::encode(Sha256::digest(bytes)), + ); + } + + if include_traversal { + let name = "files/../outside.bin"; + writer.start_file(name, options)?; + writer.write_all(b"bad")?; + checksums.insert(name.to_string(), hex::encode(Sha256::digest(b"bad"))); + } + + if include_checksums { + let mut entries = checksums; + if let Some(overrides) = checksum_overrides { + for (name, digest) in overrides { + entries.insert(name.clone(), digest.clone()); + } + } + let checksum_contents = entries + .into_iter() + .map(|(name, digest)| format!("{digest} {name}\n")) + .collect::(); + writer.start_file(OXDOOR_CHECKSUM_FILE, options)?; + writer.write_all(checksum_contents.as_bytes())?; + } + + writer.finish()?; + Ok(()) + } + + fn test_config_with_doors_root(doors_root: &Path, runtime_root: &Path) -> crate::config::OxideConfig { + let mut config: crate::config::OxideConfig = + toml::from_str("[board]\nname = \"Test\"\n").expect("config"); + config.paths.doors = doors_root.to_path_buf(); + config.paths.runtime = runtime_root.to_path_buf(); + config + } + + #[test] + fn plan_door_package_import_generates_readable_plan_for_valid_package() { + let temp = test_temp_dir(); + let package_path = temp.join("valid.oxdoor"); + let doors_root = temp.join("doors"); + let runtime = temp.join("runtime"); + let config = test_config_with_doors_root(&doors_root, &runtime); + fs::create_dir_all(&doors_root).expect("doors root"); + + write_dry_run_fixture( + &package_path, + &build_dry_run_manifest("freeware", "full", "DOOR.SYS", "local:dosemu2"), + &[("readme.txt", b"hello\n")], + true, + false, + None, + ) + .expect("write package"); + + let db = oxidebbs_db::OxideDb::open_memory().expect("open db"); + let before_count = list_door_definitions(db.db()).expect("list doors").len(); + let report = plan_door_package_import(&db, &config, &package_path) + .expect("plan"); + assert_eq!(report.package_id, "sample-package"); + assert_eq!(report.package_name, "Sample Package"); + assert_eq!(report.target_door_id, "sample-door"); + assert_eq!(report.target_install_directory, doors_root.join("sample")); + assert!(!report.target_install_directory_exists); + assert!(!report.door_definition_exists); + assert!(!report.will_enable); + assert_eq!(report.file_count, 1); + assert_eq!(report.total_unpacked_size, 6); + assert!(report.blocking_errors.is_empty()); + assert_eq!( + report.would_create_door, + DoorPackageImportDryRunDefinition { + key: "sample-door".to_string(), + name: "Sample Door".to_string(), + runner: "dosemu2".to_string(), + working_dir: "sample".to_string(), + command: "START.BAT".to_string(), + drop_file: "DOOR.SYS".to_string(), + exclusive: true, + time_limit_minutes: 2, + enabled: false, + min_security_level: 10 + } + ); + assert_eq!( + list_door_definitions(db.db()).expect("list after plan").len(), + before_count + ); + assert!(!report.target_install_directory.exists()); + cleanup_temp_dir(&temp); + } + + #[test] + fn plan_door_package_import_detects_existing_target_directory() { + let temp = test_temp_dir(); + let package_path = temp.join("conflict-dir.oxdoor"); + let doors_root = temp.join("doors"); + let runtime = temp.join("runtime"); + let config = test_config_with_doors_root(&doors_root, &runtime); + fs::create_dir_all(doors_root.join("sample")).expect("target exists"); + + write_dry_run_fixture( + &package_path, + &build_dry_run_manifest("freeware", "full", "DOOR.SYS", "local:dosemu2"), + &[("readme.txt", b"hello\n")], + true, + false, + None, + ) + .expect("write package"); + + let db = oxidebbs_db::OxideDb::open_memory().expect("open db"); + let report = plan_door_package_import(&db, &config, &package_path).expect("plan"); + assert!(report.target_install_directory_exists); + assert!(report + .blocking_errors + .iter() + .any(|error| error.contains("target door directory"))); + cleanup_temp_dir(&temp); + } + + #[test] + fn plan_door_package_import_detects_existing_door_definition() { + let temp = test_temp_dir(); + let package_path = temp.join("conflict-door.oxdoor"); + let doors_root = temp.join("doors"); + let runtime = temp.join("runtime"); + let config = test_config_with_doors_root(&doors_root, &runtime); + fs::create_dir_all(&doors_root).expect("doors root"); + + write_dry_run_fixture( + &package_path, + &build_dry_run_manifest("freeware", "full", "DOOR.SYS", "local:dosemu2"), + &[("readme.txt", b"hello\n")], + true, + false, + None, + ) + .expect("write package"); + + let db = oxidebbs_db::OxideDb::open_memory().expect("open db"); + insert_door_definition( + db.db(), + &DoorDefinitionRecord { + id: "00000000-0000-4000-8000-000000000001".to_string(), + key: "sample-door".to_string(), + name: "Sample Door".to_string(), + runner: "dosemu2".to_string(), + working_dir: "sample".to_string(), + command: "START.BAT".to_string(), + drop_file: "DOOR.SYS".to_string(), + exclusive: true, + time_limit_minutes: 2, + enabled: false, + min_security_level: 10, + }, + ) + .expect("insert existing door"); + let report = plan_door_package_import(&db, &config, &package_path).expect("plan"); + assert!(report.door_definition_exists); + assert!(report + .blocking_errors + .iter() + .any(|error| error.contains("already exists"))); + cleanup_temp_dir(&temp); + } + + #[test] + fn plan_door_package_import_flags_legal_hold_as_blocking() { + let temp = test_temp_dir(); + let package_path = temp.join("legal-hold.oxdoor"); + let doors_root = temp.join("doors"); + let runtime = temp.join("runtime"); + let config = test_config_with_doors_root(&doors_root, &runtime); + fs::create_dir_all(&doors_root).expect("doors root"); + + write_dry_run_fixture( + &package_path, + &build_dry_run_manifest("legal_hold", "full", "DOOR.SYS", "local:dosemu2"), + &[("readme.txt", b"hello\n")], + true, + false, + None, + ) + .expect("write package"); + + let db = oxidebbs_db::OxideDb::open_memory().expect("open db"); + let report = plan_door_package_import(&db, &config, &package_path).expect("plan"); + assert!(report.blocking_errors.iter().any(|error| { + error.contains("legal_hold") + })); + cleanup_temp_dir(&temp); + } + + #[test] + fn plan_door_package_import_rejects_checksum_mismatch_before_plan() { + let temp = test_temp_dir(); + let package_path = temp.join("bad-checksum.oxdoor"); + let doors_root = temp.join("doors"); + let runtime = temp.join("runtime"); + let config = test_config_with_doors_root(&doors_root, &runtime); + fs::create_dir_all(&doors_root).expect("doors root"); + + let mut overrides = HashMap::new(); + overrides.insert("files/readme.txt".to_string(), "ff".repeat(32) + "11"); + write_dry_run_fixture( + &package_path, + &build_dry_run_manifest("freeware", "full", "DOOR.SYS", "local:dosemu2"), + &[("readme.txt", b"hello\n")], + true, + false, + Some(&overrides), + ) + .expect("write package"); + + let db = oxidebbs_db::OxideDb::open_memory().expect("open db"); + let error = plan_door_package_import(&db, &config, &package_path).expect_err("checksum mismatch"); + assert!(error.to_string().contains("checksum mismatch")); + cleanup_temp_dir(&temp); + } + + #[test] + fn plan_door_package_import_rejects_path_traversal_entries() { + let temp = test_temp_dir(); + let package_path = temp.join("path-traversal.oxdoor"); + let doors_root = temp.join("doors"); + let runtime = temp.join("runtime"); + let config = test_config_with_doors_root(&doors_root, &runtime); + fs::create_dir_all(&doors_root).expect("doors root"); + + write_dry_run_fixture( + &package_path, + &build_dry_run_manifest("freeware", "full", "DOOR.SYS", "local:dosemu2"), + &[("readme.txt", b"hello\n")], + true, + true, + None, + ) + .expect("write package"); + + let db = oxidebbs_db::OxideDb::open_memory().expect("open db"); + let error = plan_door_package_import(&db, &config, &package_path) + .expect_err("path traversal blocked"); + assert!(error.to_string().contains("traversal")); + cleanup_temp_dir(&temp); + } + + #[test] + fn plan_door_package_import_rejects_unsupported_runner() { + let temp = test_temp_dir(); + let package_path = temp.join("bad-runner.oxdoor"); + let doors_root = temp.join("doors"); + let runtime = temp.join("runtime"); + let config = test_config_with_doors_root(&doors_root, &runtime); + fs::create_dir_all(&doors_root).expect("doors root"); + + write_dry_run_fixture( + &package_path, + &build_dry_run_manifest("freeware", "full", "DOOR.SYS", "remote:doorparty"), + &[("readme.txt", b"hello\n")], + true, + false, + None, + ) + .expect("write package"); + + let db = oxidebbs_db::OxideDb::open_memory().expect("open db"); + let error = plan_door_package_import(&db, &config, &package_path).expect_err("unsupported runner"); + assert!(error.to_string().contains("unsupported runner")); + cleanup_temp_dir(&temp); + } + + #[test] + fn plan_door_package_import_rejects_unsupported_drop_file() { + let temp = test_temp_dir(); + let package_path = temp.join("bad-drop.oxdoor"); + let doors_root = temp.join("doors"); + let runtime = temp.join("runtime"); + let config = test_config_with_doors_root(&doors_root, &runtime); + fs::create_dir_all(&doors_root).expect("doors root"); + + write_dry_run_fixture( + &package_path, + &build_dry_run_manifest("freeware", "full", "BAD.DROP", "local:dosemu2"), + &[("readme.txt", b"hello\n")], + true, + false, + None, + ) + .expect("write package"); + + let db = oxidebbs_db::OxideDb::open_memory().expect("open db"); + let error = plan_door_package_import(&db, &config, &package_path).expect_err("unsupported drop"); + assert!(error.to_string().contains("unsupported drop-file format")); + cleanup_temp_dir(&temp); + } + + #[test] + fn run_door_package_import_rejects_missing_dry_run_flag() { + let temp = test_temp_dir(); + let package_path = temp.join("missing-flag.oxdoor"); + let runtime = temp.join("runtime"); + let mut config: crate::config::OxideConfig = + toml::from_str("[board]\nname = \"Test\"\n").expect("config"); + config.paths.runtime = runtime; + config.database.path = temp.join("database.ddb"); + let ctx = crate::sysop_cli::AppContext { + config_path: temp.join("oxidebbs.toml"), + config, + json: false, + }; + + let error = run_door_package_import(package_path, false, &ctx) + .expect_err("dry-run required"); + assert!(error.to_string().contains("dry-run only")); + cleanup_temp_dir(&temp); + } + #[test] fn doors_list_json_shape_matches_contract() { let doors = vec![DoorDefinitionRecord { diff --git a/docs/project/doors.md b/docs/project/doors.md index cb54808..eb4c3cd 100644 --- a/docs/project/doors.md +++ b/docs/project/doors.md @@ -97,6 +97,7 @@ Door run history is visible through: ```bash oxidebbs-server doors package inspect sample.oxdoor +oxidebbs-server doors package import sample.oxdoor --dry-run oxidebbs-server doors runs list oxidebbs-server doors runs show oxidebbs-server doors cleanup diff --git a/docs/project/sysop-cli.md b/docs/project/sysop-cli.md index c04e8ae..701d72f 100644 --- a/docs/project/sysop-cli.md +++ b/docs/project/sysop-cli.md @@ -445,6 +445,7 @@ Door management: - `oxidebbs-server doors enable ` - `oxidebbs-server doors disable ` - `oxidebbs-server doors package inspect ` +- `oxidebbs-server doors package import --dry-run` - `oxidebbs-server doors test --user sysop --dry-run` - `oxidebbs-server doors dropfile --user sysop --node 1 --format DORINFO1.DEF` - `oxidebbs-server doors runs list` @@ -456,6 +457,9 @@ Meaning: - `--dry-run` generates drop files and validates input without launching a child. - `doors package inspect` parses `.oxdoor` packages, validates manifest, checksum, and file safety constraints, then prints a read-only summary. +- `doors package import --dry-run` validates packages for import, computes the target + install directory and door definition, and prints a planned import report with + conflicts, warnings, and follow-up commands. - `doors dropfile --format` supports `DOOR.SYS`, `DORINFO1.DEF`, `CHAIN.TXT`, `DOORFILE.SR`, `PCBOARD.SYS`, and `CALLINFO.BBS`. - Live interactive DOS door testing requires a caller session. Start `serve`, From bbeaf022c4ce66c1d288eac006a1270b047966d6 Mon Sep 17 00:00:00 2001 From: Steven Hildreth Date: Sun, 7 Jun 2026 17:42:09 -0500 Subject: [PATCH 3/9] feat(oxdoor): implement package installation and activation logic Add the core implementation for importing OXDOOR packages into the system. This enables the actual deployment of door resources and database registration, complementing the previously added dry-run capability. - Implement the full import workflow with database synchronization - Add `--enable` flag to activate doors immediately after import - Add `--no-check` flag to bypass post-installation validation - Include menu category metadata in import plans and reports - Provide actionable follow-up commands after successful installation --- crates/oxidebbs-server/src/commands/doors.rs | 518 ++++++++++++++++++- 1 file changed, 498 insertions(+), 20 deletions(-) diff --git a/crates/oxidebbs-server/src/commands/doors.rs b/crates/oxidebbs-server/src/commands/doors.rs index 99cb7d0..a3a111f 100644 --- a/crates/oxidebbs-server/src/commands/doors.rs +++ b/crates/oxidebbs-server/src/commands/doors.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::fs; +use std::io::{self, Read}; #[cfg(unix)] use std::os::unix::fs::MetadataExt; use std::path::{Path, PathBuf}; @@ -119,6 +120,10 @@ pub enum DoorPackageCommand { path: PathBuf, #[arg(long)] dry_run: bool, + #[arg(long)] + enable: bool, + #[arg(long)] + no_check: bool, }, } @@ -217,7 +222,12 @@ fn print_check_issues(issues: &[CheckIssue]) { fn run_door_package(args: DoorPackageCommand, ctx: &AppContext) -> CliResult<()> { match args { DoorPackageCommand::Inspect { path } => run_door_package_inspect(path, ctx), - DoorPackageCommand::Import { path, dry_run } => run_door_package_import(path, dry_run, ctx), + DoorPackageCommand::Import { + path, + dry_run, + enable, + no_check, + } => run_door_package_import(path, dry_run, enable, no_check, ctx), } } @@ -313,6 +323,7 @@ struct DoorPackageImportDryRun { file_count: usize, total_unpacked_size: u64, will_enable: bool, + menu_category: String, would_create_door: DoorPackageImportDryRunDefinition, warnings: Vec, blocking_errors: Vec, @@ -326,6 +337,7 @@ impl DoorPackageImportDryRun { "package_id": self.package_id, "package_name": self.package_name, "target_door_id": self.target_door_id, + "menu_category": self.menu_category, "target_install_directory": self.target_install_directory, "target_install_directory_exists": self.target_install_directory_exists, "door_definition_exists": self.door_definition_exists, @@ -340,24 +352,182 @@ impl DoorPackageImportDryRun { } } -fn run_door_package_import(path: PathBuf, dry_run: bool, ctx: &AppContext) -> CliResult<()> { - if !dry_run { +fn run_door_package_import( + path: PathBuf, + dry_run: bool, + enable: bool, + no_check: bool, + ctx: &AppContext, +) -> CliResult<()> { + let db = open_database(&ctx.config)?; + let report = plan_door_package_import(&db, &ctx.config, &path)?; + + let should_print_plan = dry_run || !report.blocking_errors.is_empty(); + if should_print_plan { + if ctx.json { + print_json(&report.to_json())?; + } else { + print_door_package_import_dry_run_report(&report)?; + } + } + if !report.blocking_errors.is_empty() { + return Err(CliError::Message(format!( + "package import blocked: {}", + report.blocking_errors.join("; ") + ))); + } + if dry_run { + return Ok(()); + } + + perform_door_package_import(&db, &ctx.config, &report, &path, enable)?; + let imported_door = require_effective_door(db.db(), &ctx.config, &report.target_door_id)?; + if !ctx.json { + print_door_package_import_completion_report(&report, &imported_door); + } + let check_report = if !no_check { + let check = check_door(&imported_door, &ctx.config, &db)?; + if check.issues.is_empty() { + if !ctx.json { + println!("post-import check: ok"); + } + Some(check) + } else { + if !ctx.json { + println!("post-import check:"); + print_check_issues(&check.issues); + } + Some(check) + } + } else if !ctx.json { + println!("post-import check skipped (use --no-check)"); + } + let has_check_errors = check_report + .as_ref() + .is_some_and(|check| check.issues.iter().any(|issue| issue.level == "error")); + if !ctx.json { + println!("Suggested follow-up commands:"); + for command in &report.follow_up_commands { + println!(" - {command}"); + } + } else { + let check_issues = check_report + .as_ref() + .map(|check| check.issues.iter().map(CheckIssue::to_json).collect::>()) + .unwrap_or_default(); + let target_install_directory_exists = report.target_install_directory.exists(); + print_json(&serde_json::json!({ + "status": "completed", + "package_path": report.package_path, + "package_id": report.package_id, + "package_name": report.package_name, + "target_door_id": report.target_door_id, + "target_install_directory": report.target_install_directory, + "target_install_directory_exists": target_install_directory_exists, + "door_definition_exists": report.door_definition_exists, + "file_count": report.file_count, + "total_unpacked_size": report.total_unpacked_size, + "installed": true, + "door_definition": { + "key": imported_door.key.clone(), + "name": imported_door.name.clone(), + "runner": imported_door.runner.clone(), + "working_dir": imported_door.working_dir.clone(), + "command": imported_door.command.clone(), + "drop_file": imported_door.drop_file.clone(), + "exclusive": imported_door.exclusive, + "time_limit_minutes": imported_door.time_limit_minutes, + "enabled": imported_door.enabled, + "min_security_level": imported_door.min_security_level, + }, + "warnings": report.warnings, + "blocking_errors": report.blocking_errors, + "follow_up_commands": report.follow_up_commands, + "post_import_check": { + "enabled": !no_check, + "issues": check_issues, + }, + }))?; + } + + if has_check_errors { return Err(CliError::Message( - "package import is dry-run only in this release; pass --dry-run".to_string(), + "package import post-check failed".to_string(), )); } + Ok(()) +} - let db = open_database(&ctx.config)?; - let report = plan_door_package_import(&db, &ctx.config, &path)?; - if ctx.json { - print_json(&report.to_json())?; +fn print_door_package_import_completion_report( + report: &DoorPackageImportDryRun, + imported_door: &DoorDefinitionRecord, +) { + let target_exists = report.target_install_directory.exists(); + println!( + "package.path: {}", + report.package_path.display() + ); + println!("package.id: {}", report.package_id); + println!("package.name: {}", report.package_name); + println!("target.door_id: {}", imported_door.key); + println!( + "target.install_directory: {}", + report.target_install_directory.display() + ); + println!( + "target.install_directory_exists: {}", + if target_exists { "yes" } else { "no" } + ); + println!( + "door.definition_exists: {}", + if report.door_definition_exists { "yes" } else { "no" } + ); + println!("file.count: {}", report.file_count); + println!( + "file.total_unpacked_size: {}", + report.total_unpacked_size + ); + println!("enabled: {}", if imported_door.enabled { "yes" } else { "no" }); + println!("door.definition:"); + println!(" key: {}", imported_door.key); + println!(" name: {}", imported_door.name); + println!(" runner: {}", imported_door.runner); + println!(" working_dir: {}", imported_door.working_dir); + println!(" command: {}", imported_door.command); + println!(" drop_file: {}", imported_door.drop_file); + println!(" exclusive: {}", imported_door.exclusive); + println!( + " time_limit_minutes: {}", + imported_door.time_limit_minutes + ); + println!( + " min_security_level: {}", + imported_door.min_security_level + ); + println!(" enabled: {}", if imported_door.enabled { "yes" } else { "no" }); + if report.warnings.is_empty() { + println!("warnings: (none)"); } else { - print_door_package_import_dry_run_report(&report)?; + println!("warnings:"); + for warning in &report.warnings { + println!(" - {warning}"); + } } - if !report.blocking_errors.is_empty() { - return Err(CliError::Message("package import dry-run has blocking errors".to_string())); + if report.blocking_errors.is_empty() { + println!("blocking_errors: (none)"); + } else { + println!("blocking_errors:"); + for issue in &report.blocking_errors { + println!(" - {issue}"); + } } - Ok(()) + println!( + "Menu hint: category {}, label {}, suggested key {}", + report.menu_category, imported_door.name, imported_door.key + ); + println!( + "The existing Doors menu action will expose enabled configured doors. No menu files were changed." + ); } fn print_door_package_import_dry_run_report(report: &DoorPackageImportDryRun) -> CliResult<()> { @@ -560,6 +730,7 @@ fn plan_door_package_import( package_id: summary.package_id, package_name: summary.package_name, target_door_id: summary.door_id, + menu_category: summary.door_category, target_install_directory, target_install_directory_exists, door_definition_exists, @@ -573,6 +744,120 @@ fn plan_door_package_import( }) } +fn perform_door_package_import( + db: &oxidebbs_db::OxideDb, + config: &crate::config::OxideConfig, + report: &DoorPackageImportDryRun, + package_path: &Path, + enable: bool, +) -> CliResult<()> { + if report.target_install_directory_exists || report.target_install_directory.exists() { + return Err(CliError::Message(format!( + "import target directory {} already exists", + report.target_install_directory.display() + ))); + } + + let configured_root = match std::path::Path::new(&config.paths.doors).canonicalize() { + Ok(root) => root, + Err(_) => config.paths.doors.clone(), + }; + if !report.target_install_directory.starts_with(&configured_root) { + return Err(CliError::Message( + "import target directory is outside configured doors root; aborting import".to_string(), + )); + } + + if let Err(error) = extract_oxide_door_package_files(package_path, &report.target_install_directory) { + let _ = fs::remove_dir_all(&report.target_install_directory); + return Err(error); + } + + let record = DoorDefinitionRecord { + id: generated_uuid(db)?, + key: report.would_create_door.key.clone(), + name: report.would_create_door.name.clone(), + runner: report.would_create_door.runner.clone(), + working_dir: report.would_create_door.working_dir.clone(), + command: report.would_create_door.command.clone(), + drop_file: report.would_create_door.drop_file.clone(), + exclusive: report.would_create_door.exclusive, + time_limit_minutes: i64::from(report.would_create_door.time_limit_minutes), + enabled: enable, + min_security_level: i64::from(report.would_create_door.min_security_level), + }; + if let Err(error) = insert_door_definition(db.db(), &record) { + let _ = fs::remove_dir_all(&report.target_install_directory); + return Err(error.into()); + } + + audit( + db, + "door:import", + None, + None, + &format!( + "imported door {} ({}) into {}", + record.key, record.id, report.target_install_directory.display() + ), + )?; + + Ok(()) +} + +fn extract_oxide_door_package_files(package_path: &Path, target_dir: &Path) -> CliResult<()> { + let file = fs::File::open(package_path)?; + let mut archive = zip::ZipArchive::new(file).map_err(|source| { + CliError::Message(format!("invalid door package ZIP while importing: {source}")) + })?; + if let Some(parent) = target_dir.parent() { + fs::create_dir_all(parent).map_err(CliError::from)?; + } + fs::create_dir_all(target_dir).map_err(CliError::from)?; + + for index in 0..archive.len() { + let mut entry = archive.by_index(index).map_err(|source| { + CliError::Message(format!("invalid package entry at index {index}: {source}")) + })?; + let entry_name = entry.name().to_string(); + if entry_name.ends_with('/') { + continue; + } + if entry.is_dir() { + continue; + } + if entry_name == oxidebbs_door::OXDOOR_MANIFEST_FILE + || entry_name == oxidebbs_door::OXDOOR_CHECKSUM_FILE + { + continue; + } + if !entry_name.starts_with("files/") { + continue; + } + + let relative = entry_name + .strip_prefix("files/") + .ok_or_else(|| CliError::Message(format!("invalid files path {entry_name}")))?; + if relative.is_empty() { + continue; + } + + let out_path = target_dir.join(relative); + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent).map_err(CliError::from)?; + } + let mut out = fs::File::create(&out_path).map_err(CliError::from)?; + if let Err(error) = io::copy(&mut entry, &mut out).map_err(|error| { + CliError::Message(format!("failed to write file {}: {error}", out_path.display())) + }) { + let _ = fs::remove_dir_all(target_dir); + return Err(error); + } + } + + Ok(()) +} + fn is_supported_drop_file(format: &str) -> bool { matches!( format.to_ascii_uppercase().as_str(), @@ -2152,23 +2437,216 @@ timeout_seconds = 120 } #[test] - fn run_door_package_import_rejects_missing_dry_run_flag() { + fn run_door_package_import_imports_package_into_doors_root() { let temp = test_temp_dir(); - let package_path = temp.join("missing-flag.oxdoor"); + let package_path = temp.join("import-success.oxdoor"); + let doors_root = temp.join("doors"); let runtime = temp.join("runtime"); - let mut config: crate::config::OxideConfig = - toml::from_str("[board]\nname = \"Test\"\n").expect("config"); - config.paths.runtime = runtime; + let mut config = test_config_with_doors_root(&doors_root, &runtime); config.database.path = temp.join("database.ddb"); + fs::create_dir_all(&doors_root).expect("doors root"); + fs::create_dir_all(config.paths.runtime.clone()).expect("runtime"); + + write_dry_run_fixture( + &package_path, + &build_dry_run_manifest("freeware", "full", "DOOR.SYS", "local:dosemu2"), + &[ + ("readme.txt", b"hello\n"), + ("nested/config.ini", b"option=1\n"), + ], + true, + false, + None, + ) + .expect("write package"); + + let ctx = crate::sysop_cli::AppContext { + config_path: temp.join("oxidebbs.toml"), + config: config.clone(), + json: false, + }; + run_door_package_import(package_path, false, false, true, &ctx).expect("import"); + + let db = oxidebbs_db::OxideDb::open_or_create(&ctx.config.database.path).expect("open db"); + let door = find_door_by_key(db.db(), "sample-door") + .expect("find") + .expect("imported door"); + assert!(!door.enabled); + assert_eq!(door.working_dir, "sample"); + assert_eq!(list_door_definitions(db.db()).expect("list").len(), 1); + + let target_install = doors_root.join("sample"); + assert!(target_install.join("readme.txt").is_file()); + assert!(target_install.join("nested/config.ini").is_file()); + assert_eq!( + fs::read_to_string(target_install.join("readme.txt")).expect("readme"), + "hello\n" + ); + assert_eq!( + fs::read_to_string(target_install.join("nested/config.ini")).expect("config"), + "option=1\n" + ); + cleanup_temp_dir(&temp); + } + + #[test] + fn run_door_package_import_enables_when_enable_flag_set() { + let temp = test_temp_dir(); + let package_path = temp.join("import-enable.oxdoor"); + let doors_root = temp.join("doors"); + let runtime = temp.join("runtime"); + let mut config = test_config_with_doors_root(&doors_root, &runtime); + config.database.path = temp.join("database.ddb"); + fs::create_dir_all(&doors_root).expect("doors root"); + fs::create_dir_all(config.paths.runtime.clone()).expect("runtime"); + + write_dry_run_fixture( + &package_path, + &build_dry_run_manifest("freeware", "full", "DOOR.SYS", "local:dosemu2"), + &[("readme.txt", b"hello\n")], + true, + false, + None, + ) + .expect("write package"); + + let ctx = crate::sysop_cli::AppContext { + config_path: temp.join("oxidebbs.toml"), + config, + json: false, + }; + run_door_package_import(package_path, false, true, true, &ctx) + .expect("import with --enable"); + + let db = oxidebbs_db::OxideDb::open_or_create(&ctx.config.database.path).expect("open db"); + let door = find_door_by_key(db.db(), "sample-door") + .expect("find") + .expect("imported door"); + assert!(door.enabled); + cleanup_temp_dir(&temp); + } + + #[test] + fn run_door_package_import_rejects_existing_door_definition_without_side_effects() { + let temp = test_temp_dir(); + let package_path = temp.join("conflict-door-import.oxdoor"); + let doors_root = temp.join("doors"); + let runtime = temp.join("runtime"); + let mut config = test_config_with_doors_root(&doors_root, &runtime); + config.database.path = temp.join("database.ddb"); + fs::create_dir_all(&doors_root).expect("doors root"); + fs::create_dir_all(config.paths.runtime.clone()).expect("runtime"); + + write_dry_run_fixture( + &package_path, + &build_dry_run_manifest("freeware", "full", "DOOR.SYS", "local:dosemu2"), + &[("readme.txt", b"hello\n")], + true, + false, + None, + ) + .expect("write package"); + + let db = oxidebbs_db::OxideDb::open_or_create(&config.database.path).expect("open db"); + insert_door_definition( + db.db(), + &DoorDefinitionRecord { + id: "00000000-0000-4000-8000-000000000001".to_string(), + key: "sample-door".to_string(), + name: "Sample Door".to_string(), + runner: "dosemu2".to_string(), + working_dir: "sample".to_string(), + command: "START.BAT".to_string(), + drop_file: "DOOR.SYS".to_string(), + exclusive: true, + time_limit_minutes: 2, + enabled: false, + min_security_level: 10, + }, + ) + .expect("insert existing door"); + drop(db); + + let ctx = crate::sysop_cli::AppContext { + config_path: temp.join("oxidebbs.toml"), + config, + json: false, + }; + let error = + run_door_package_import(package_path, false, false, true, &ctx).expect_err("existing door"); + assert!(error.to_string().contains("already exists")); + assert!(!doors_root.join("sample").exists()); + + let db = oxidebbs_db::OxideDb::open_or_create(&ctx.config.database.path).expect("open db"); + assert_eq!(list_door_definitions(db.db()).expect("list").len(), 1); + cleanup_temp_dir(&temp); + } + + #[test] + fn run_door_package_import_rejects_checksum_mismatch_without_writing() { + let temp = test_temp_dir(); + let package_path = temp.join("bad-checksum-import.oxdoor"); + let doors_root = temp.join("doors"); + let runtime = temp.join("runtime"); + let mut config = test_config_with_doors_root(&doors_root, &runtime); + config.database.path = temp.join("database.ddb"); + fs::create_dir_all(&doors_root).expect("doors root"); + fs::create_dir_all(config.paths.runtime.clone()).expect("runtime"); + + let mut overrides = HashMap::new(); + overrides.insert("files/readme.txt".to_string(), "ff".repeat(32) + "11"); + write_dry_run_fixture( + &package_path, + &build_dry_run_manifest("freeware", "full", "DOOR.SYS", "local:dosemu2"), + &[("readme.txt", b"hello\n")], + true, + false, + Some(&overrides), + ) + .expect("write package"); + let ctx = crate::sysop_cli::AppContext { config_path: temp.join("oxidebbs.toml"), config, json: false, }; + let error = + run_door_package_import(package_path, false, false, true, &ctx).expect_err("checksum mismatch"); + assert!(error.to_string().contains("checksum mismatch")); + assert!(!doors_root.join("sample").exists()); + cleanup_temp_dir(&temp); + } + + #[test] + fn run_door_package_import_rejects_path_traversal_without_writing() { + let temp = test_temp_dir(); + let package_path = temp.join("bad-path-import.oxdoor"); + let doors_root = temp.join("doors"); + let runtime = temp.join("runtime"); + let mut config = test_config_with_doors_root(&doors_root, &runtime); + config.database.path = temp.join("database.ddb"); + fs::create_dir_all(&doors_root).expect("doors root"); + fs::create_dir_all(config.paths.runtime.clone()).expect("runtime"); - let error = run_door_package_import(package_path, false, &ctx) - .expect_err("dry-run required"); - assert!(error.to_string().contains("dry-run only")); + write_dry_run_fixture( + &package_path, + &build_dry_run_manifest("freeware", "full", "DOOR.SYS", "local:dosemu2"), + &[("readme.txt", b"hello\n")], + true, + true, + None, + ) + .expect("write package"); + + let ctx = crate::sysop_cli::AppContext { + config_path: temp.join("oxidebbs.toml"), + config, + json: false, + }; + let error = run_door_package_import(package_path, false, false, true, &ctx) + .expect_err("path traversal"); + assert!(error.to_string().contains("traversal")); + assert!(!doors_root.join("sample").exists()); cleanup_temp_dir(&temp); } From dda3602ad87331251ca257329fa773194d22552e Mon Sep 17 00:00:00 2001 From: Steven Hildreth Date: Sun, 12 Jul 2026 21:30:00 -0500 Subject: [PATCH 4/9] Refactor error handling in sysop CLI and sysop library to use boxed DoorError - Updated `CliError` in `sysop_cli.rs` to store `DoorError` as a `Box`. - Implemented a custom `From` implementation for converting `DoorError` to `CliError`. - Modified `SysopError` in `lib.rs` to store `DoorError` as a `Box` and added a corresponding `From` implementation. Enhance ZMODEM tests for byte escaping - Changed test data in `zdle_escape_does_not_escape_normal_bytes` to use a byte string for clarity. Update OxDoor package format documentation - Replaced references to `artifacts/` with `tests/` in `OXDOOR_FORMAT_V1.md`. - Clarified package import behavior and added details about the `--enable` and `--replace` flags. - Updated documentation to reflect the new structure and rules for OxDoor packages. Add constants for default configurations in OxideBBS core - Introduced `constants.rs` to define default time limit for door runs and default TCP port for Binkp. - Added unit tests in `constants_test.rs` to verify the expected values of the constants. --- CHANGELOG.md | 1 + crates/oxidebbs-core/src/constants.rs | 7 + crates/oxidebbs-core/tests/constants_test.rs | 10 + crates/oxidebbs-door/src/lib.rs | 9 +- crates/oxidebbs-door/src/oxdoor_package.rs | 363 ++++++--- crates/oxidebbs-server/Cargo.toml | 2 +- crates/oxidebbs-server/src/commands/doors.rs | 740 ++++++++++++++++--- crates/oxidebbs-server/src/sysop_cli.rs | 8 +- crates/oxidebbs-sysop/src/lib.rs | 8 +- crates/oxidebbs-transfer/src/zmodem.rs | 2 +- design/OXDOOR_FORMAT_V1.md | 20 +- docs/.vitepress/config.mts | 2 + docs/OXDOOR_FORMAT_V1.md | 18 +- docs/project/doors.md | 21 +- docs/project/sysop-cli.md | 13 +- 15 files changed, 966 insertions(+), 258 deletions(-) create mode 100644 crates/oxidebbs-core/src/constants.rs create mode 100644 crates/oxidebbs-core/tests/constants_test.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c919dba..0409c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,3 +7,4 @@ The canonical OxideBBS changelog is maintained in the documentation site source: Keep this root file as a pointer so package managers, repository browsers, and contributors can find the current changelog without duplicating release notes. +- Added `constants.rs` to centralise default configuration values such as the default door time limit and Binkp port. diff --git a/crates/oxidebbs-core/src/constants.rs b/crates/oxidebbs-core/src/constants.rs new file mode 100644 index 0000000..79bab40 --- /dev/null +++ b/crates/oxidebbs-core/src/constants.rs @@ -0,0 +1,7 @@ +// Constants used across the OxideBBS core + +/// Default time limit in minutes for a door run if none specified. +pub const DEFAULT_TIME_LIMIT_MINUTES: u32 = 30; + +/// Default TCP port for Binkp if none specified. +pub const DEFAULT_BINKP_PORT: u16 = 24554; diff --git a/crates/oxidebbs-core/tests/constants_test.rs b/crates/oxidebbs-core/tests/constants_test.rs new file mode 100644 index 0000000..0e8d4a4 --- /dev/null +++ b/crates/oxidebbs-core/tests/constants_test.rs @@ -0,0 +1,10 @@ +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constants_have_expected_values() { + assert_eq!(DEFAULT_TIME_LIMIT_MINUTES, 30); + assert_eq!(DEFAULT_BINKP_PORT, 24554); + } +} diff --git a/crates/oxidebbs-door/src/lib.rs b/crates/oxidebbs-door/src/lib.rs index 7245a71..38bd1bc 100644 --- a/crates/oxidebbs-door/src/lib.rs +++ b/crates/oxidebbs-door/src/lib.rs @@ -20,7 +20,9 @@ use thiserror::Error; pub const CRATE_NAME: &str = "oxidebbs-door"; pub use crate::oxdoor_package::{ - inspect_oxide_door_package, OxDoorPackageInspection, OxDoorPackageSummary, + OXDOOR_CHECKSUM_FILE, OXDOOR_MANIFEST_FILE, OXDOOR_PACKAGE_FORMAT, OXDOOR_PACKAGE_KIND_FULL, + OXDOOR_SUPPORTED_DROPFILES, OxDoorPackageInspection, OxDoorPackageSummary, + inspect_oxide_door_package, }; #[derive(Debug, Error)] @@ -59,10 +61,7 @@ pub enum DoorError { }, #[error("invalid door package {path}: {message}")] - InvalidDoorPackage { - path: PathBuf, - message: String, - }, + InvalidDoorPackage { path: PathBuf, message: String }, } #[derive(Debug, Clone, Deserialize)] diff --git a/crates/oxidebbs-door/src/oxdoor_package.rs b/crates/oxidebbs-door/src/oxdoor_package.rs index 432d1b3..092d906 100644 --- a/crates/oxidebbs-door/src/oxdoor_package.rs +++ b/crates/oxidebbs-door/src/oxdoor_package.rs @@ -1,9 +1,8 @@ use std::collections::{HashMap, HashSet}; use std::fs::File; use std::io::Read; -use std::path::{Component, Path, PathBuf}; +use std::path::{Component, Path}; -use hex; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use zip::ZipArchive; @@ -24,6 +23,8 @@ pub const OXDOOR_SUPPORTED_DROPFILES: [&str; 6] = [ ]; const FILES_DIRECTORY: &str = "files/"; +const DOCS_DIRECTORY: &str = "docs/"; +const TESTS_DIRECTORY: &str = "tests/"; #[derive(Debug, Clone, Serialize)] pub struct OxDoorPackageSummary { @@ -123,7 +124,7 @@ struct DoorSection { pub enabled_after_import: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] struct AccessSection { #[serde(default)] pub min_security_level: i32, @@ -137,19 +138,7 @@ struct AccessSection { pub timeout_seconds: Option, } -impl Default for AccessSection { - fn default() -> Self { - Self { - min_security_level: 0, - preferred_drop_file: None, - supported_drop_files: Vec::new(), - exclusive: None, - timeout_seconds: None, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] struct PersistenceSection { #[serde(default)] pub enabled_after_import_request: Option, @@ -157,30 +146,13 @@ struct PersistenceSection { pub timeout_seconds: Option, } -impl Default for PersistenceSection { - fn default() -> Self { - Self { - enabled_after_import_request: None, - timeout_seconds: None, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] struct TestSection { #[serde(default)] pub timeout_seconds: Option, } -impl Default for TestSection { - fn default() -> Self { - Self { - timeout_seconds: None, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] struct MenuSection { #[serde(default)] pub category: Option, @@ -190,16 +162,6 @@ struct MenuSection { pub timeout_seconds: Option, } -impl Default for MenuSection { - fn default() -> Self { - Self { - category: None, - exclusive: None, - timeout_seconds: None, - } - } -} - impl DoorSection { fn working_directory(&self) -> Option<&str> { self.working_directory @@ -334,49 +296,44 @@ fn validate_manifest( }); } - let package_name = trim_required(&manifest.package.name) - .ok_or_else(|| DoorError::InvalidDoorPackage { + let package_name = + trim_required(&manifest.package.name).ok_or_else(|| DoorError::InvalidDoorPackage { path: package_path.to_path_buf(), message: "package.name is required".to_string(), })?; - let package_id = trim_required(&manifest.package.id).ok_or_else(|| { - DoorError::InvalidDoorPackage { + let package_id = + trim_required(&manifest.package.id).ok_or_else(|| DoorError::InvalidDoorPackage { path: package_path.to_path_buf(), message: "package.id is required".to_string(), - } - })?; - let package_version = trim_required(&manifest.package.version).ok_or_else(|| { - DoorError::InvalidDoorPackage { + })?; + let package_version = + trim_required(&manifest.package.version).ok_or_else(|| DoorError::InvalidDoorPackage { path: package_path.to_path_buf(), message: "package.version is required".to_string(), - } - })?; + })?; validate_id_characters(package_path, "package.id", &package_id)?; - let legal_status = trim_required(&manifest.legal.status).ok_or_else(|| { - DoorError::InvalidDoorPackage { + let legal_status = + trim_required(&manifest.legal.status).ok_or_else(|| DoorError::InvalidDoorPackage { path: package_path.to_path_buf(), message: "legal.status is required".to_string(), - } - })?; + })?; let requires_key = manifest .legal .requires_key .or(manifest.package.requires_key) .unwrap_or(false); - let door_id = trim_required(&manifest.door.id).ok_or_else(|| { - DoorError::InvalidDoorPackage { + let door_id = + trim_required(&manifest.door.id).ok_or_else(|| DoorError::InvalidDoorPackage { path: package_path.to_path_buf(), message: "door.id is required".to_string(), - } - })?; - let door_name = trim_required(&manifest.door.name).ok_or_else(|| { - DoorError::InvalidDoorPackage { + })?; + let door_name = + trim_required(&manifest.door.name).ok_or_else(|| DoorError::InvalidDoorPackage { path: package_path.to_path_buf(), message: "door.name is required".to_string(), - } - })?; + })?; validate_id_characters(package_path, "door.id", &door_id)?; if manifest.door.runner.trim().is_empty() { @@ -385,7 +342,12 @@ fn validate_manifest( message: "door.runner is required".to_string(), }); } - if !manifest.door.runner.trim().eq_ignore_ascii_case("local:dosemu2") { + if !manifest + .door + .runner + .trim() + .eq_ignore_ascii_case("local:dosemu2") + { return Err(DoorError::InvalidDoorPackage { path: package_path.to_path_buf(), message: "unsupported runner; v1 packages must set door.runner = \"local:dosemu2\"" @@ -393,12 +355,11 @@ fn validate_manifest( }); } - let command = trim_required(&manifest.door.command).ok_or_else(|| { - DoorError::InvalidDoorPackage { + let command = + trim_required(&manifest.door.command).ok_or_else(|| DoorError::InvalidDoorPackage { path: package_path.to_path_buf(), message: "door.command is required".to_string(), - } - })?; + })?; let working_directory = manifest .door @@ -419,7 +380,8 @@ fn validate_manifest( .map(|value| normalize_drop_file(package_path, value)) .transpose()? .or_else(|| { - manifest.access + manifest + .access .supported_drop_files .first() .map(|value| normalize_drop_file(package_path, value)) @@ -427,11 +389,9 @@ fn validate_manifest( .ok() .flatten() }) - .ok_or_else(|| { - DoorError::InvalidDoorPackage { - path: package_path.to_path_buf(), - message: "preferred drop-file format is required".to_string(), - } + .ok_or_else(|| DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: "preferred drop-file format is required".to_string(), })?; let mut supported_drop_files = Vec::new(); @@ -446,13 +406,18 @@ fn validate_manifest( } supported_drop_files.sort(); supported_drop_files.dedup(); - if manifest.door.supported_drop_files.is_empty() && manifest.access.supported_drop_files.is_empty() { + if manifest.door.supported_drop_files.is_empty() + && manifest.access.supported_drop_files.is_empty() + { return Err(DoorError::InvalidDoorPackage { path: package_path.to_path_buf(), message: "door-support section requires supported drop-file formats".to_string(), }); } - if !supported_drop_files.iter().any(|value| value == &preferred_drop_file) { + if !supported_drop_files + .iter() + .any(|value| value == &preferred_drop_file) + { return Err(DoorError::InvalidDoorPackage { path: package_path.to_path_buf(), message: format!( @@ -550,10 +515,13 @@ fn verify_files( let mut verified = HashSet::new(); for index in 0..archive.len() { - let mut entry = archive.by_index(index).map_err(|source| DoorError::InvalidDoorPackage { - path: package_path.to_path_buf(), - message: format!("invalid archive entry at index {index}: {source}"), - })?; + let mut entry = + archive + .by_index(index) + .map_err(|source| DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!("invalid archive entry at index {index}: {source}"), + })?; let entry_name = entry.name().to_string(); validate_entry_name(package_path, &entry_name)?; validate_entry_mode(package_path, &entry_name, &entry)?; @@ -562,6 +530,30 @@ fn verify_files( continue; } if entry.is_dir() || entry_name.ends_with('/') { + if !is_allowed_package_directory(&entry_name) { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!("unsupported package directory {entry_name:?}"), + }); + } + continue; + } + if !is_allowed_package_file(&entry_name) { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!("unsupported package file {entry_name:?}"), + }); + } + if entry_name.starts_with(DOCS_DIRECTORY) || entry_name.starts_with(TESTS_DIRECTORY) { + let expected = + checksums + .get(&entry_name) + .ok_or_else(|| DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!("missing checksum for package file {entry_name:?}"), + })?; + verify_file_checksum(package_path, &mut entry, expected)?; + verified.insert(entry_name); continue; } if !entry_name.starts_with(FILES_DIRECTORY) { @@ -579,11 +571,9 @@ fn verify_files( .or_else(|| checksums.get(&format!("./{entry_name}"))) .or_else(|| checksums.get(entry_relative)) .or_else(|| checksums.get(&format!("./{entry_relative}"))); - let expected = expected.ok_or_else(|| { - DoorError::InvalidDoorPackage { - path: package_path.to_path_buf(), - message: format!("missing checksum for file {entry_relative:?}"), - } + let expected = expected.ok_or_else(|| DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!("missing checksum for file {entry_relative:?}"), })?; verify_file_checksum(package_path, &mut entry, expected)?; verified.insert(format!("files/{entry_relative}")); @@ -593,7 +583,11 @@ fn verify_files( for expected_path in checksums.keys() { let normalized = normalize_for_lookup(expected_path); - if normalized.starts_with(FILES_DIRECTORY) && !verified.contains(&normalized) { + if (normalized.starts_with(FILES_DIRECTORY) + || normalized.starts_with(DOCS_DIRECTORY) + || normalized.starts_with(TESTS_DIRECTORY)) + && !verified.contains(&normalized) + { return Err(DoorError::InvalidDoorPackage { path: package_path.to_path_buf(), message: format!("checksum entry has no matching files/ payload: {expected_path}"), @@ -611,22 +605,37 @@ fn verify_files( Ok((file_count, total_unpacked_size)) } +fn is_allowed_package_file(entry_name: &str) -> bool { + entry_name.starts_with(FILES_DIRECTORY) + || entry_name.starts_with(DOCS_DIRECTORY) + || entry_name.starts_with(TESTS_DIRECTORY) +} + +fn is_allowed_package_directory(entry_name: &str) -> bool { + matches!(entry_name, "files/" | "docs/" | "tests/") + || entry_name.starts_with(FILES_DIRECTORY) + || entry_name.starts_with(DOCS_DIRECTORY) + || entry_name.starts_with(TESTS_DIRECTORY) +} + fn read_text_entry( archive: &mut ZipArchive, package_path: &Path, entry_name: &str, ) -> Result { - let mut entry = archive.by_name(entry_name).map_err(|_| DoorError::InvalidDoorPackage { - path: package_path.to_path_buf(), - message: format!("missing required entry: {entry_name}"), - })?; + let mut entry = archive + .by_name(entry_name) + .map_err(|_| DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!("missing required entry: {entry_name}"), + })?; let mut text = String::new(); - entry.read_to_string(&mut text).map_err(|source| { - DoorError::ReadDoorPackage { + entry + .read_to_string(&mut text) + .map_err(|source| DoorError::ReadDoorPackage { path: package_path.to_path_buf(), source, - } - })?; + })?; Ok(text) } @@ -696,6 +705,21 @@ fn validate_entry_name(package_path: &Path, entry_name: &str) -> Result<(), Door ), }); } + let trimmed = entry_name.trim_end_matches('/'); + if trimmed.is_empty() || trimmed.split('/').any(str::is_empty) { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!("invalid path {entry_name:?}: empty path components are not allowed"), + }); + } + if trimmed.split('/').any(is_windows_drive_path) { + return Err(DoorError::InvalidDoorPackage { + path: package_path.to_path_buf(), + message: format!( + "invalid path {entry_name:?}: windows drive-style path components are not allowed" + ), + }); + } for component in Path::new(entry_name).components() { match component { @@ -766,12 +790,12 @@ fn verify_file_checksum( let mut hasher = Sha256::new(); let mut buffer = [0u8; 8192]; loop { - let count = entry.read(&mut buffer).map_err(|source| { - DoorError::ReadDoorPackage { + let count = entry + .read(&mut buffer) + .map_err(|source| DoorError::ReadDoorPackage { path: package_path.to_path_buf(), source, - } - })?; + })?; if count == 0 { break; } @@ -807,23 +831,18 @@ fn validate_id_characters(package_path: &Path, field: &str, value: &str) -> Resu fn trim_required(value: &str) -> Option { let value = value.trim().to_string(); - if value.is_empty() { - None - } else { - Some(value) - } + if value.is_empty() { None } else { Some(value) } } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; - use std::io; + use std::io::{self, Write}; + use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; - use zip::write::{FileOptions, SimpleFileOptions}; use zip::ZipWriter; - - use crate::OXDOOR_PACKAGE_FORMAT; + use zip::write::{FileOptions, SimpleFileOptions}; fn build_manifest(override_runner: &str, preferred_drop_file: &str, kind: &str) -> String { format!( @@ -929,6 +948,45 @@ timeout_seconds = 120 Ok(()) } + fn write_fixture_with_extra_entries( + path: &Path, + extra_entries: &[(&str, &[u8], Option)], + ) -> io::Result<()> { + let file = File::create(path)?; + let mut writer = ZipWriter::new(file); + let options: FileOptions<'_, ()> = + SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + let manifest = build_manifest("local:dosemu2", "DOOR.SYS", "full"); + + writer.start_file(OXDOOR_MANIFEST_FILE, options)?; + writer.write_all(manifest.as_bytes())?; + + let mut checksums = HashMap::new(); + writer.start_file(format!("{FILES_DIRECTORY}readme.txt"), options)?; + writer.write_all(b"hello")?; + checksums.insert( + format!("{FILES_DIRECTORY}readme.txt"), + hex::encode(Sha256::digest(b"hello")), + ); + + for (name, bytes, mode) in extra_entries { + let entry_options = mode.map_or(options, |mode| options.unix_permissions(mode)); + writer.start_file(*name, entry_options)?; + writer.write_all(bytes)?; + checksums.insert((*name).to_string(), hex::encode(Sha256::digest(bytes))); + } + + let checksum_contents = checksums + .into_iter() + .map(|(name, digest)| format!("{digest} {name}\n")) + .collect::(); + writer.start_file(OXDOOR_CHECKSUM_FILE, options)?; + writer.write_all(checksum_contents.as_bytes())?; + + writer.finish()?; + Ok(()) + } + fn temp_dir() -> PathBuf { let path = std::env::temp_dir().join(format!( "oxidebbs-oxdoor-test-{}-{}", @@ -971,7 +1029,10 @@ timeout_seconds = 120 assert_eq!(summary.file_count, 2); assert_eq!(summary.total_unpacked_size, 12); assert_eq!(summary.preferred_drop_file, "DOOR.SYS"); - assert_eq!(summary.supported_drop_files, vec!["CHAIN.TXT","DORINFO1.DEF","DOOR.SYS"]); + assert_eq!( + summary.supported_drop_files, + vec!["CHAIN.TXT", "DOOR.SYS", "DORINFO1.DEF"] + ); cleanup(&temp); } @@ -1040,6 +1101,26 @@ timeout_seconds = 120 cleanup(&temp); } + #[test] + fn inspect_package_rejects_invalid_format() { + let temp = temp_dir(); + let package_path = temp.join("bad-format.oxdoor"); + let manifest = build_manifest("local:dosemu2", "DOOR.SYS", "full") + .replace(OXDOOR_PACKAGE_FORMAT, "oxide-door-package-v2"); + write_fixture( + &package_path, + &manifest, + &[("readme.txt", b"hello")], + true, + false, + None, + ) + .expect("write"); + let error = inspect_oxide_door_package(&package_path).expect_err("bad format"); + assert!(error.to_string().contains("unsupported package format")); + cleanup(&temp); + } + #[test] fn inspect_package_rejects_unsupported_runner() { let temp = temp_dir(); @@ -1080,8 +1161,7 @@ timeout_seconds = 120 fn inspect_package_rejects_checksum_mismatch() { let temp = temp_dir(); let package_path = temp.join("bad-checksum.oxdoor"); - let overrides = - HashMap::from([("files/readme.txt".to_string(), "ff".repeat(32) + "11")]); + let overrides = HashMap::from([("files/readme.txt".to_string(), "00".repeat(32))]); write_fixture( &package_path, &build_manifest("local:dosemu2", "DOOR.SYS", "full"), @@ -1115,4 +1195,59 @@ timeout_seconds = 120 ); cleanup(&temp); } + + #[test] + fn inspect_package_rejects_absolute_path_entry() { + let temp = temp_dir(); + let package_path = temp.join("absolute-path.oxdoor"); + write_fixture_with_extra_entries(&package_path, &[("/tmp/evil.exe", b"bad", None)]) + .expect("write"); + let error = inspect_oxide_door_package(&package_path).expect_err("absolute path"); + assert!(error.to_string().contains("absolute paths")); + cleanup(&temp); + } + + #[test] + fn inspect_package_rejects_backslash_path_entry() { + let temp = temp_dir(); + let package_path = temp.join("backslash-path.oxdoor"); + write_fixture_with_extra_entries(&package_path, &[("files\\..\\evil.exe", b"bad", None)]) + .expect("write"); + let error = inspect_oxide_door_package(&package_path).expect_err("backslash path"); + assert!(error.to_string().contains("backslashes")); + cleanup(&temp); + } + + #[test] + fn inspect_package_rejects_double_slash_under_files() { + let temp = temp_dir(); + let package_path = temp.join("double-slash.oxdoor"); + write_fixture_with_extra_entries(&package_path, &[("files//tmp/evil.exe", b"bad", None)]) + .expect("write"); + let error = inspect_oxide_door_package(&package_path).expect_err("double slash"); + assert!(error.to_string().contains("empty path components")); + cleanup(&temp); + } + + #[test] + fn inspect_package_rejects_nested_windows_drive_path_entry() { + let temp = temp_dir(); + let package_path = temp.join("windows-drive.oxdoor"); + write_fixture_with_extra_entries(&package_path, &[("files/C:/evil.exe", b"bad", None)]) + .expect("write"); + let error = inspect_oxide_door_package(&package_path).expect_err("windows drive"); + assert!(error.to_string().contains("windows drive-style")); + cleanup(&temp); + } + + #[test] + fn inspect_package_rejects_extra_root_files() { + let temp = temp_dir(); + let package_path = temp.join("extra-root.oxdoor"); + write_fixture_with_extra_entries(&package_path, &[("postinstall.sh", b"echo bad", None)]) + .expect("write"); + let error = inspect_oxide_door_package(&package_path).expect_err("extra root file"); + assert!(error.to_string().contains("unsupported package file")); + cleanup(&temp); + } } diff --git a/crates/oxidebbs-server/Cargo.toml b/crates/oxidebbs-server/Cargo.toml index 30effb9..1e0231f 100644 --- a/crates/oxidebbs-server/Cargo.toml +++ b/crates/oxidebbs-server/Cargo.toml @@ -32,6 +32,7 @@ tower-cookies.workspace = true tower-sessions.workspace = true tracing.workspace = true tracing-subscriber.workspace = true +zip.workspace = true [target.'cfg(unix)'.dependencies] nix.workspace = true @@ -41,4 +42,3 @@ rcgen = "0.14.8" futures-util = "0.3" tokio-tungstenite = "0.28" tower = "0.5" -zip.workspace = true diff --git a/crates/oxidebbs-server/src/commands/doors.rs b/crates/oxidebbs-server/src/commands/doors.rs index a3a111f..2b3a9cf 100644 --- a/crates/oxidebbs-server/src/commands/doors.rs +++ b/crates/oxidebbs-server/src/commands/doors.rs @@ -1,9 +1,9 @@ use std::collections::HashMap; use std::fs; -use std::io::{self, Read}; +use std::io; #[cfg(unix)] use std::os::unix::fs::MetadataExt; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use clap::{Args, Subcommand}; #[cfg(unix)] @@ -121,6 +121,8 @@ pub enum DoorPackageCommand { #[arg(long)] dry_run: bool, #[arg(long)] + replace: bool, + #[arg(long)] enable: bool, #[arg(long)] no_check: bool, @@ -225,23 +227,38 @@ fn run_door_package(args: DoorPackageCommand, ctx: &AppContext) -> CliResult<()> DoorPackageCommand::Import { path, dry_run, + replace, enable, no_check, - } => run_door_package_import(path, dry_run, enable, no_check, ctx), + } => run_door_package_import(path, dry_run, replace, enable, no_check, ctx), } } fn run_door_package_inspect(path: PathBuf, ctx: &AppContext) -> CliResult<()> { let summary = oxidebbs_door::inspect_oxide_door_package(&path)?; + let target_install_directory = planned_door_package_install_directory( + &ctx.config.paths.doors, + &summary.working_directory, + )?; if ctx.json { - print_json(&serde_json::to_value(&summary).map_err(CliError::from)?)?; + let mut value = serde_json::to_value(&summary).map_err(CliError::from)?; + if let Some(object) = value.as_object_mut() { + object.insert( + "target_install_directory".to_string(), + json!(target_install_directory), + ); + } + print_json(&value)?; } else { println!("package.name: {}", summary.package_name); println!("package.id: {}", summary.package_id); println!("package.version: {}", summary.package_version); println!("package.kind: {}", summary.package_kind); println!("package.legal_status: {}", summary.legal_status); - println!("package.requires_key: {}", if summary.requires_key { "yes" } else { "no" }); + println!( + "package.requires_key: {}", + if summary.requires_key { "yes" } else { "no" } + ); if let Some(source_url) = summary.source_url.as_deref() { println!("package.source_url: {}", source_url); } @@ -251,20 +268,28 @@ fn run_door_package_inspect(path: PathBuf, ctx: &AppContext) -> CliResult<()> { println!("door.runner: {}", summary.runner); println!("door.command: {}", summary.command); println!("door.working_directory: {}", summary.working_directory); - println!( - "door.preferred_drop_file: {}", - summary.preferred_drop_file - ); + println!("door.preferred_drop_file: {}", summary.preferred_drop_file); println!( "door.supported_drop_files: {}", summary.supported_drop_files.join(", ") ); - println!("door.exclusive: {}", if summary.exclusive { "yes" } else { "no" }); + println!( + "door.exclusive: {}", + if summary.exclusive { "yes" } else { "no" } + ); println!("door.timeout_seconds: {}", summary.timeout_seconds); println!("access.min_security_level: {}", summary.min_security_level); + println!( + "target.install_directory: {}", + target_install_directory.display() + ); println!( "persistence.enabled_after_import_request: {}", - if summary.enabled_after_import_request { "yes" } else { "no" } + if summary.enabled_after_import_request { + "yes" + } else { + "no" + } ); println!("files.count: {}", summary.file_count); println!("files.total_unpacked_size: {}", summary.total_unpacked_size); @@ -294,6 +319,21 @@ struct DoorPackageImportDryRunDefinition { min_security_level: i32, } +#[derive(Debug, Clone)] +struct DoorPackageImportFileCopy { + package_path: String, + destination_path: PathBuf, +} + +impl DoorPackageImportFileCopy { + fn to_json(&self) -> JsonValue { + json!({ + "package_path": self.package_path, + "destination_path": self.destination_path, + }) + } +} + impl DoorPackageImportDryRunDefinition { fn to_json(&self) -> JsonValue { json!({ @@ -320,8 +360,10 @@ struct DoorPackageImportDryRun { target_install_directory: PathBuf, target_install_directory_exists: bool, door_definition_exists: bool, + replace: bool, file_count: usize, total_unpacked_size: u64, + planned_file_copies: Vec, will_enable: bool, menu_category: String, would_create_door: DoorPackageImportDryRunDefinition, @@ -341,8 +383,14 @@ impl DoorPackageImportDryRun { "target_install_directory": self.target_install_directory, "target_install_directory_exists": self.target_install_directory_exists, "door_definition_exists": self.door_definition_exists, + "replace": self.replace, "file_count": self.file_count, "total_unpacked_size": self.total_unpacked_size, + "planned_file_copies": self + .planned_file_copies + .iter() + .map(DoorPackageImportFileCopy::to_json) + .collect::>(), "will_enable": self.will_enable, "would_create_door": self.would_create_door.to_json(), "warnings": self.warnings, @@ -355,33 +403,45 @@ impl DoorPackageImportDryRun { fn run_door_package_import( path: PathBuf, dry_run: bool, + replace: bool, enable: bool, no_check: bool, ctx: &AppContext, ) -> CliResult<()> { - let db = open_database(&ctx.config)?; - let report = plan_door_package_import(&db, &ctx.config, &path)?; - - let should_print_plan = dry_run || !report.blocking_errors.is_empty(); - if should_print_plan { + if dry_run { + let dry_run_db = open_existing_database_for_dry_run(&ctx.config)?; + let report = plan_door_package_import(dry_run_db.as_ref(), &ctx.config, &path, replace)?; if ctx.json { print_json(&report.to_json())?; } else { print_door_package_import_dry_run_report(&report)?; } + if !report.blocking_errors.is_empty() { + return Err(CliError::Message(format!( + "package import blocked: {}", + report.blocking_errors.join("; ") + ))); + } + return Ok(()); } + + let db = open_database(&ctx.config)?; + let report = plan_door_package_import(Some(db.db()), &ctx.config, &path, replace)?; + if !report.blocking_errors.is_empty() { + if ctx.json { + print_json(&report.to_json())?; + } else { + print_door_package_import_dry_run_report(&report)?; + } return Err(CliError::Message(format!( "package import blocked: {}", report.blocking_errors.join("; ") ))); } - if dry_run { - return Ok(()); - } - perform_door_package_import(&db, &ctx.config, &report, &path, enable)?; - let imported_door = require_effective_door(db.db(), &ctx.config, &report.target_door_id)?; + perform_door_package_import(&db, &ctx.config, &report, &path, replace, enable)?; + let imported_door = require_effective_door(&db, &ctx.config, &report.target_door_id)?; if !ctx.json { print_door_package_import_completion_report(&report, &imported_door); } @@ -399,9 +459,12 @@ fn run_door_package_import( } Some(check) } - } else if !ctx.json { - println!("post-import check skipped (use --no-check)"); - } + } else { + if !ctx.json { + println!("post-import check skipped (use --no-check)"); + } + None + }; let has_check_errors = check_report .as_ref() .is_some_and(|check| check.issues.iter().any(|issue| issue.level == "error")); @@ -413,7 +476,13 @@ fn run_door_package_import( } else { let check_issues = check_report .as_ref() - .map(|check| check.issues.iter().map(CheckIssue::to_json).collect::>()) + .map(|check| { + check + .issues + .iter() + .map(CheckIssue::to_json) + .collect::>() + }) .unwrap_or_default(); let target_install_directory_exists = report.target_install_directory.exists(); print_json(&serde_json::json!({ @@ -463,10 +532,7 @@ fn print_door_package_import_completion_report( imported_door: &DoorDefinitionRecord, ) { let target_exists = report.target_install_directory.exists(); - println!( - "package.path: {}", - report.package_path.display() - ); + println!("package.path: {}", report.package_path.display()); println!("package.id: {}", report.package_id); println!("package.name: {}", report.package_name); println!("target.door_id: {}", imported_door.key); @@ -480,14 +546,19 @@ fn print_door_package_import_completion_report( ); println!( "door.definition_exists: {}", - if report.door_definition_exists { "yes" } else { "no" } + if report.door_definition_exists { + "yes" + } else { + "no" + } ); + println!("replace: {}", if report.replace { "yes" } else { "no" }); println!("file.count: {}", report.file_count); + println!("file.total_unpacked_size: {}", report.total_unpacked_size); println!( - "file.total_unpacked_size: {}", - report.total_unpacked_size + "enabled: {}", + if imported_door.enabled { "yes" } else { "no" } ); - println!("enabled: {}", if imported_door.enabled { "yes" } else { "no" }); println!("door.definition:"); println!(" key: {}", imported_door.key); println!(" name: {}", imported_door.name); @@ -496,15 +567,12 @@ fn print_door_package_import_completion_report( println!(" command: {}", imported_door.command); println!(" drop_file: {}", imported_door.drop_file); println!(" exclusive: {}", imported_door.exclusive); + println!(" time_limit_minutes: {}", imported_door.time_limit_minutes); + println!(" min_security_level: {}", imported_door.min_security_level); println!( - " time_limit_minutes: {}", - imported_door.time_limit_minutes + " enabled: {}", + if imported_door.enabled { "yes" } else { "no" } ); - println!( - " min_security_level: {}", - imported_door.min_security_level - ); - println!(" enabled: {}", if imported_door.enabled { "yes" } else { "no" }); if report.warnings.is_empty() { println!("warnings: (none)"); } else { @@ -521,6 +589,14 @@ fn print_door_package_import_completion_report( println!(" - {issue}"); } } + println!("planned_file_copies:"); + for copy in &report.planned_file_copies { + println!( + " - {} -> {}", + copy.package_path, + copy.destination_path.display() + ); + } println!( "Menu hint: category {}, label {}, suggested key {}", report.menu_category, imported_door.name, imported_door.key @@ -540,18 +616,33 @@ fn print_door_package_import_dry_run_report(report: &DoorPackageImportDryRun) -> println!("package.id: {}", report.package_id); println!("package.name: {}", report.package_name); println!("package.door_id: {}", report.target_door_id); - println!("target.install_directory: {}", report.target_install_directory.display()); + println!( + "target.install_directory: {}", + report.target_install_directory.display() + ); println!( "target.install_directory_exists: {}", - if report.target_install_directory_exists { "yes" } else { "no" } + if report.target_install_directory_exists { + "yes" + } else { + "no" + } ); println!( "door.definition_exists: {}", - if report.door_definition_exists { "yes" } else { "no" } + if report.door_definition_exists { + "yes" + } else { + "no" + } ); + println!("replace: {}", if report.replace { "yes" } else { "no" }); println!("file.count: {}", report.file_count); println!("file.total_unpacked_size: {}", report.total_unpacked_size); - println!("door.will_enable: {}", if report.will_enable { "yes" } else { "no" }); + println!( + "door.will_enable: {}", + if report.will_enable { "yes" } else { "no" } + ); println!("door.definition:"); println!(" key: {}", report.would_create_door.key); println!(" name: {}", report.would_create_door.name); @@ -559,7 +650,14 @@ fn print_door_package_import_dry_run_report(report: &DoorPackageImportDryRun) -> println!(" working_dir: {}", report.would_create_door.working_dir); println!(" command: {}", report.would_create_door.command); println!(" drop_file: {}", report.would_create_door.drop_file); - println!(" exclusive: {}", if report.would_create_door.exclusive { "yes" } else { "no" }); + println!( + " exclusive: {}", + if report.would_create_door.exclusive { + "yes" + } else { + "no" + } + ); println!( " time_limit_minutes: {}", report.would_create_door.time_limit_minutes @@ -568,7 +666,14 @@ fn print_door_package_import_dry_run_report(report: &DoorPackageImportDryRun) -> " min_security_level: {}", report.would_create_door.min_security_level ); - println!(" enabled: {}", if report.would_create_door.enabled { "yes" } else { "no" }); + println!( + " enabled: {}", + if report.would_create_door.enabled { + "yes" + } else { + "no" + } + ); if report.warnings.is_empty() { println!("warnings: (none)"); } else { @@ -585,6 +690,14 @@ fn print_door_package_import_dry_run_report(report: &DoorPackageImportDryRun) -> println!(" - {issue}"); } } + println!("planned_file_copies:"); + for copy in &report.planned_file_copies { + println!( + " - {} -> {}", + copy.package_path, + copy.destination_path.display() + ); + } println!("follow_up_commands:"); for command in &report.follow_up_commands { println!(" - {command}"); @@ -593,9 +706,10 @@ fn print_door_package_import_dry_run_report(report: &DoorPackageImportDryRun) -> } fn plan_door_package_import( - db: &oxidebbs_db::OxideDb, + db: Option<&oxidebbs_db::Db>, config: &crate::config::OxideConfig, package_path: &Path, + replace: bool, ) -> CliResult { let summary = oxidebbs_door::inspect_oxide_door_package(package_path)?; let mut warnings = summary.warnings.clone(); @@ -608,14 +722,20 @@ fn plan_door_package_import( "doors root {} is not accessible yet: {error}", config.paths.doors.display() )); - config.paths.doors.clone() + absolute_config_path(&config.paths.doors)? } }; - let target_install_directory = doors_root.join(&summary.working_directory); - let target_install_directory_exists = target_install_directory.exists(); - if target_install_directory_exists { + let target_install_directory = + planned_door_package_install_directory(&doors_root, &summary.working_directory)?; + let target_install_directory_exists = import_path_exists(&target_install_directory); + if target_install_directory_exists && !replace { blocking_errors.push(format!( - "target door directory already exists: {}", + "target door directory already exists: {}; pass --replace to replace it", + target_install_directory.display() + )); + } else if target_install_directory_exists { + warnings.push(format!( + "target door directory will be replaced: {}", target_install_directory.display() )); } @@ -627,7 +747,8 @@ fn plan_door_package_import( let runner = if summary.runner.eq_ignore_ascii_case("local:dosemu2") { "dosemu2".to_string() } else { - blocking_errors.push("unsupported package runner; only local:dosemu2 is supported".to_string()); + blocking_errors + .push("unsupported package runner; only local:dosemu2 is supported".to_string()); summary.runner.clone() }; @@ -656,7 +777,7 @@ fn plan_door_package_import( blocking_errors.push("no supported drop-file formats".to_string()); } - let mut timeout_minutes = (summary.timeout_seconds + 59) / 60; + let mut timeout_minutes = summary.timeout_seconds.div_ceil(60); if summary.timeout_seconds % 60 != 0 { warnings.push(format!( "timeout_seconds {} is not aligned to minutes; import will use {} minutes", @@ -667,15 +788,18 @@ fn plan_door_package_import( timeout_minutes = 1; } let time_limit_minutes = u32::try_from(timeout_minutes).map_err(|_| { - CliError::Message(format!("invalid timeout_minutes {timeout_minutes} while preparing import")) + CliError::Message(format!( + "invalid timeout_minutes {timeout_minutes} while preparing import" + )) })?; + let working_dir = target_install_directory.to_string_lossy().to_string(); if let Err(error) = validate_door_fields_before_write( &summary.door_id, &summary.command, time_limit_minutes, &summary.preferred_drop_file, None, - &summary.working_directory, + &working_dir, ) { blocking_errors.push(error.to_string()); } @@ -688,11 +812,24 @@ fn plan_door_package_import( ); } - let existing = find_door_by_key(db.db(), &summary.door_id)?; + let existing = if let Some(db) = db { + find_door_by_key(db, &summary.door_id)? + } else { + warnings.push(format!( + "database {} does not exist; door definition conflict check was skipped", + config.database.path.display() + )); + None + }; let door_definition_exists = existing.is_some(); - if door_definition_exists { + if door_definition_exists && !replace { blocking_errors.push(format!( - "door definition {} already exists and would conflict", + "door definition {} already exists and would conflict; pass --replace to update it", + summary.door_id + )); + } else if door_definition_exists { + warnings.push(format!( + "door definition {} will be updated", summary.door_id )); } @@ -701,7 +838,7 @@ fn plan_door_package_import( key: summary.door_id.clone(), name: summary.door_name.clone(), runner, - working_dir: summary.working_directory.clone(), + working_dir, command: summary.command.clone(), drop_file: summary.preferred_drop_file.clone(), exclusive: summary.exclusive, @@ -711,19 +848,15 @@ fn plan_door_package_import( }; let follow_up_commands = vec![ - format!( - "oxidebbs-server doors check {}", - summary.door_id - ), + format!("oxidebbs-server doors check {}", summary.door_id), format!( "oxidebbs-server doors test {} --user sysop --dry-run", summary.door_id ), - format!( - "oxidebbs-server doors enable {}", - summary.door_id - ), + format!("oxidebbs-server doors enable {}", summary.door_id), ]; + let planned_file_copies = + planned_oxide_door_package_file_copies(package_path, &target_install_directory)?; Ok(DoorPackageImportDryRun { package_path: package_path.to_path_buf(), @@ -734,8 +867,10 @@ fn plan_door_package_import( target_install_directory, target_install_directory_exists, door_definition_exists, + replace, file_count: summary.file_count, total_unpacked_size: summary.total_unpacked_size, + planned_file_copies, will_enable, would_create_door, warnings, @@ -749,32 +884,54 @@ fn perform_door_package_import( config: &crate::config::OxideConfig, report: &DoorPackageImportDryRun, package_path: &Path, + replace: bool, enable: bool, ) -> CliResult<()> { - if report.target_install_directory_exists || report.target_install_directory.exists() { + if (report.target_install_directory_exists + || import_path_exists(&report.target_install_directory)) + && !replace + { return Err(CliError::Message(format!( "import target directory {} already exists", report.target_install_directory.display() ))); } - let configured_root = match std::path::Path::new(&config.paths.doors).canonicalize() { - Ok(root) => root, - Err(_) => config.paths.doors.clone(), - }; - if !report.target_install_directory.starts_with(&configured_root) { + fs::create_dir_all(&config.paths.doors).map_err(CliError::from)?; + let configured_root = std::path::Path::new(&config.paths.doors).canonicalize()?; + if !report + .target_install_directory + .starts_with(&configured_root) + { return Err(CliError::Message( "import target directory is outside configured doors root; aborting import".to_string(), )); } + if import_path_exists(&report.target_install_directory) { + remove_replace_import_target(&report.target_install_directory)?; + } - if let Err(error) = extract_oxide_door_package_files(package_path, &report.target_install_directory) { + let existing = find_door_by_key(db.db(), &report.would_create_door.key)?; + if existing.is_some() && !replace { + return Err(CliError::Message(format!( + "door definition {} already exists", + report.would_create_door.key + ))); + } + let record_id = match existing.as_ref() { + Some(door) => door.id.clone(), + None => generated_uuid(db)?, + }; + + if let Err(error) = + extract_oxide_door_package_files(package_path, &report.target_install_directory) + { let _ = fs::remove_dir_all(&report.target_install_directory); return Err(error); } let record = DoorDefinitionRecord { - id: generated_uuid(db)?, + id: record_id, key: report.would_create_door.key.clone(), name: report.would_create_door.name.clone(), runner: report.would_create_door.runner.clone(), @@ -786,7 +943,12 @@ fn perform_door_package_import( enabled: enable, min_security_level: i64::from(report.would_create_door.min_security_level), }; - if let Err(error) = insert_door_definition(db.db(), &record) { + let write_result = if existing.is_some() { + update_door_definition(db.db(), &record) + } else { + insert_door_definition(db.db(), &record) + }; + if let Err(error) = write_result { let _ = fs::remove_dir_all(&report.target_install_directory); return Err(error.into()); } @@ -798,7 +960,9 @@ fn perform_door_package_import( None, &format!( "imported door {} ({}) into {}", - record.key, record.id, report.target_install_directory.display() + record.key, + record.id, + report.target_install_directory.display() ), )?; @@ -808,18 +972,23 @@ fn perform_door_package_import( fn extract_oxide_door_package_files(package_path: &Path, target_dir: &Path) -> CliResult<()> { let file = fs::File::open(package_path)?; let mut archive = zip::ZipArchive::new(file).map_err(|source| { - CliError::Message(format!("invalid door package ZIP while importing: {source}")) + CliError::Message(format!( + "invalid door package ZIP while importing: {source}" + )) })?; if let Some(parent) = target_dir.parent() { fs::create_dir_all(parent).map_err(CliError::from)?; } fs::create_dir_all(target_dir).map_err(CliError::from)?; + let target_root = target_dir.canonicalize().map_err(CliError::from)?; for index in 0..archive.len() { let mut entry = archive.by_index(index).map_err(|source| { CliError::Message(format!("invalid package entry at index {index}: {source}")) })?; let entry_name = entry.name().to_string(); + validate_package_archive_entry_name(&entry_name)?; + validate_package_zip_entry_mode(&entry_name, &entry)?; if entry_name.ends_with('/') { continue; } @@ -835,20 +1004,30 @@ fn extract_oxide_door_package_files(package_path: &Path, target_dir: &Path) -> C continue; } - let relative = entry_name - .strip_prefix("files/") - .ok_or_else(|| CliError::Message(format!("invalid files path {entry_name}")))?; - if relative.is_empty() { + let Some(relative) = package_file_relative_path(&entry_name)? else { continue; - } + }; - let out_path = target_dir.join(relative); + let out_path = target_root.join(&relative); + if !out_path.starts_with(&target_root) { + return Err(CliError::Message(format!( + "package entry {entry_name:?} would write outside {}", + target_root.display() + ))); + } if let Some(parent) = out_path.parent() { fs::create_dir_all(parent).map_err(CliError::from)?; } - let mut out = fs::File::create(&out_path).map_err(CliError::from)?; + let mut out = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&out_path) + .map_err(CliError::from)?; if let Err(error) = io::copy(&mut entry, &mut out).map_err(|error| { - CliError::Message(format!("failed to write file {}: {error}", out_path.display())) + CliError::Message(format!( + "failed to write file {}: {error}", + out_path.display() + )) }) { let _ = fs::remove_dir_all(target_dir); return Err(error); @@ -858,6 +1037,180 @@ fn extract_oxide_door_package_files(package_path: &Path, target_dir: &Path) -> C Ok(()) } +fn planned_oxide_door_package_file_copies( + package_path: &Path, + target_dir: &Path, +) -> CliResult> { + let file = fs::File::open(package_path)?; + let mut archive = zip::ZipArchive::new(file).map_err(|source| { + CliError::Message(format!( + "invalid door package ZIP while planning import: {source}" + )) + })?; + let mut copies = Vec::new(); + for index in 0..archive.len() { + let entry = archive.by_index(index).map_err(|source| { + CliError::Message(format!("invalid package entry at index {index}: {source}")) + })?; + let entry_name = entry.name().to_string(); + validate_package_archive_entry_name(&entry_name)?; + validate_package_zip_entry_mode(&entry_name, &entry)?; + if entry.is_dir() || entry_name.ends_with('/') { + continue; + } + let Some(relative) = package_file_relative_path(&entry_name)? else { + continue; + }; + copies.push(DoorPackageImportFileCopy { + package_path: entry_name, + destination_path: target_dir.join(relative), + }); + } + copies.sort_by(|left, right| left.package_path.cmp(&right.package_path)); + Ok(copies) +} + +fn package_file_relative_path(entry_name: &str) -> CliResult> { + let Some(relative) = entry_name.strip_prefix("files/") else { + return Ok(None); + }; + validate_package_relative_path(relative)?; + Ok(Some(PathBuf::from(relative))) +} + +fn validate_package_archive_entry_name(entry_name: &str) -> CliResult<()> { + if entry_name.contains('\\') { + return Err(CliError::Message(format!( + "invalid package path {entry_name:?}: backslashes are not allowed" + ))); + } + if entry_name.starts_with('/') { + return Err(CliError::Message(format!( + "invalid package path {entry_name:?}: absolute paths are not allowed" + ))); + } + if is_windows_drive_path(entry_name) { + return Err(CliError::Message(format!( + "invalid package path {entry_name:?}: Windows drive-style paths are not allowed" + ))); + } + let trimmed = entry_name.trim_end_matches('/'); + if trimmed.is_empty() || trimmed.split('/').any(str::is_empty) { + return Err(CliError::Message(format!( + "invalid package path {entry_name:?}: empty path components are not allowed" + ))); + } + if trimmed.split('/').any(is_windows_drive_path) { + return Err(CliError::Message(format!( + "invalid package path {entry_name:?}: Windows drive-style path components are not allowed" + ))); + } + for component in Path::new(entry_name).components() { + match component { + Component::ParentDir + | Component::CurDir + | Component::RootDir + | Component::Prefix(_) => { + return Err(CliError::Message(format!( + "invalid package path {entry_name:?}: traversal is not allowed" + ))); + } + Component::Normal(_) => {} + } + } + Ok(()) +} + +fn validate_package_relative_path(relative: &str) -> CliResult<()> { + if relative.is_empty() { + return Err(CliError::Message( + "files/ entry path is invalid".to_string(), + )); + } + validate_package_archive_entry_name(relative) +} + +fn validate_package_zip_entry_mode( + entry_name: &str, + entry: &zip::read::ZipFile<'_, fs::File>, +) -> CliResult<()> { + let Some(mode) = entry.unix_mode() else { + return Ok(()); + }; + let kind = mode & 0o170000; + if kind == 0o120000 { + return Err(CliError::Message(format!( + "archive entry {entry_name:?} is a symlink" + ))); + } + if kind != 0o100000 && kind != 0o040000 { + return Err(CliError::Message(format!( + "archive entry {entry_name:?} is unsupported file type (mode {mode:o})" + ))); + } + Ok(()) +} + +fn is_windows_drive_path(value: &str) -> bool { + let mut chars = value.chars(); + matches!((chars.next(), chars.next()), (Some(drive), Some(':')) if drive.is_ascii_alphabetic()) +} + +fn import_path_exists(path: &Path) -> bool { + fs::symlink_metadata(path).is_ok() +} + +fn remove_replace_import_target(path: &Path) -> CliResult<()> { + let metadata = fs::symlink_metadata(path).map_err(CliError::from)?; + if metadata.file_type().is_symlink() { + return Err(CliError::Message(format!( + "target door path {} is a symlink; remove it manually before import", + path.display() + ))); + } + if !metadata.is_dir() { + return Err(CliError::Message(format!( + "target door path {} exists but is not a directory", + path.display() + ))); + } + fs::remove_dir_all(path).map_err(CliError::from) +} + +fn absolute_config_path(path: &Path) -> CliResult { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Ok(std::env::current_dir()?.join(path)) + } +} + +fn planned_door_package_install_directory( + doors_root: &Path, + working_directory: &str, +) -> CliResult { + let root = absolute_config_path(doors_root)?; + let target = root.join(working_directory); + if !target.starts_with(&root) { + return Err(CliError::Message( + "package working directory resolves outside configured doors root".to_string(), + )); + } + Ok(target) +} + +fn open_existing_database_for_dry_run( + config: &crate::config::OxideConfig, +) -> CliResult> { + if !config.database.path.exists() { + return Ok(None); + } + Ok(Some(oxidebbs_db::Db::open( + &config.database.path, + oxidebbs_db::DbConfig::default(), + )?)) +} + fn is_supported_drop_file(format: &str) -> bool { matches!( format.to_ascii_uppercase().as_str(), @@ -1064,6 +1417,7 @@ fn run_doors_with_db( } emit_ok(ctx.json, "door runtime directories cleaned", json!({}))?; } + DoorsCommand::Package { command } => run_door_package(command, ctx)?, } Ok(()) } @@ -2030,8 +2384,8 @@ mod tests { use std::collections::HashMap; use std::io::Write; use std::time::{SystemTime, UNIX_EPOCH}; - use zip::write::{FileOptions, SimpleFileOptions}; use zip::ZipWriter; + use zip::write::{FileOptions, SimpleFileOptions}; use super::*; @@ -2167,7 +2521,10 @@ timeout_seconds = 120 Ok(()) } - fn test_config_with_doors_root(doors_root: &Path, runtime_root: &Path) -> crate::config::OxideConfig { + fn test_config_with_doors_root( + doors_root: &Path, + runtime_root: &Path, + ) -> crate::config::OxideConfig { let mut config: crate::config::OxideConfig = toml::from_str("[board]\nname = \"Test\"\n").expect("config"); config.paths.doors = doors_root.to_path_buf(); @@ -2196,12 +2553,18 @@ timeout_seconds = 120 let db = oxidebbs_db::OxideDb::open_memory().expect("open db"); let before_count = list_door_definitions(db.db()).expect("list doors").len(); - let report = plan_door_package_import(&db, &config, &package_path) - .expect("plan"); + let report = + plan_door_package_import(Some(db.db()), &config, &package_path, false).expect("plan"); assert_eq!(report.package_id, "sample-package"); assert_eq!(report.package_name, "Sample Package"); assert_eq!(report.target_door_id, "sample-door"); - assert_eq!(report.target_install_directory, doors_root.join("sample")); + assert_eq!( + report.target_install_directory, + doors_root + .canonicalize() + .expect("doors root") + .join("sample") + ); assert!(!report.target_install_directory_exists); assert!(!report.door_definition_exists); assert!(!report.will_enable); @@ -2214,7 +2577,12 @@ timeout_seconds = 120 key: "sample-door".to_string(), name: "Sample Door".to_string(), runner: "dosemu2".to_string(), - working_dir: "sample".to_string(), + working_dir: doors_root + .canonicalize() + .expect("doors root") + .join("sample") + .to_string_lossy() + .to_string(), command: "START.BAT".to_string(), drop_file: "DOOR.SYS".to_string(), exclusive: true, @@ -2224,7 +2592,9 @@ timeout_seconds = 120 } ); assert_eq!( - list_door_definitions(db.db()).expect("list after plan").len(), + list_door_definitions(db.db()) + .expect("list after plan") + .len(), before_count ); assert!(!report.target_install_directory.exists()); @@ -2251,12 +2621,15 @@ timeout_seconds = 120 .expect("write package"); let db = oxidebbs_db::OxideDb::open_memory().expect("open db"); - let report = plan_door_package_import(&db, &config, &package_path).expect("plan"); + let report = + plan_door_package_import(Some(db.db()), &config, &package_path, false).expect("plan"); assert!(report.target_install_directory_exists); - assert!(report - .blocking_errors - .iter() - .any(|error| error.contains("target door directory"))); + assert!( + report + .blocking_errors + .iter() + .any(|error| error.contains("target door directory")) + ); cleanup_temp_dir(&temp); } @@ -2297,12 +2670,15 @@ timeout_seconds = 120 }, ) .expect("insert existing door"); - let report = plan_door_package_import(&db, &config, &package_path).expect("plan"); + let report = + plan_door_package_import(Some(db.db()), &config, &package_path, false).expect("plan"); assert!(report.door_definition_exists); - assert!(report - .blocking_errors - .iter() - .any(|error| error.contains("already exists"))); + assert!( + report + .blocking_errors + .iter() + .any(|error| error.contains("already exists")) + ); cleanup_temp_dir(&temp); } @@ -2326,10 +2702,14 @@ timeout_seconds = 120 .expect("write package"); let db = oxidebbs_db::OxideDb::open_memory().expect("open db"); - let report = plan_door_package_import(&db, &config, &package_path).expect("plan"); - assert!(report.blocking_errors.iter().any(|error| { - error.contains("legal_hold") - })); + let report = + plan_door_package_import(Some(db.db()), &config, &package_path, false).expect("plan"); + assert!( + report + .blocking_errors + .iter() + .any(|error| { error.contains("legal_hold") }) + ); cleanup_temp_dir(&temp); } @@ -2343,7 +2723,7 @@ timeout_seconds = 120 fs::create_dir_all(&doors_root).expect("doors root"); let mut overrides = HashMap::new(); - overrides.insert("files/readme.txt".to_string(), "ff".repeat(32) + "11"); + overrides.insert("files/readme.txt".to_string(), "00".repeat(32)); write_dry_run_fixture( &package_path, &build_dry_run_manifest("freeware", "full", "DOOR.SYS", "local:dosemu2"), @@ -2355,7 +2735,8 @@ timeout_seconds = 120 .expect("write package"); let db = oxidebbs_db::OxideDb::open_memory().expect("open db"); - let error = plan_door_package_import(&db, &config, &package_path).expect_err("checksum mismatch"); + let error = plan_door_package_import(Some(db.db()), &config, &package_path, false) + .expect_err("checksum mismatch"); assert!(error.to_string().contains("checksum mismatch")); cleanup_temp_dir(&temp); } @@ -2380,7 +2761,7 @@ timeout_seconds = 120 .expect("write package"); let db = oxidebbs_db::OxideDb::open_memory().expect("open db"); - let error = plan_door_package_import(&db, &config, &package_path) + let error = plan_door_package_import(Some(db.db()), &config, &package_path, false) .expect_err("path traversal blocked"); assert!(error.to_string().contains("traversal")); cleanup_temp_dir(&temp); @@ -2406,7 +2787,8 @@ timeout_seconds = 120 .expect("write package"); let db = oxidebbs_db::OxideDb::open_memory().expect("open db"); - let error = plan_door_package_import(&db, &config, &package_path).expect_err("unsupported runner"); + let error = plan_door_package_import(Some(db.db()), &config, &package_path, false) + .expect_err("unsupported runner"); assert!(error.to_string().contains("unsupported runner")); cleanup_temp_dir(&temp); } @@ -2431,7 +2813,8 @@ timeout_seconds = 120 .expect("write package"); let db = oxidebbs_db::OxideDb::open_memory().expect("open db"); - let error = plan_door_package_import(&db, &config, &package_path).expect_err("unsupported drop"); + let error = plan_door_package_import(Some(db.db()), &config, &package_path, false) + .expect_err("unsupported drop"); assert!(error.to_string().contains("unsupported drop-file format")); cleanup_temp_dir(&temp); } @@ -2465,14 +2848,22 @@ timeout_seconds = 120 config: config.clone(), json: false, }; - run_door_package_import(package_path, false, false, true, &ctx).expect("import"); + run_door_package_import(package_path, false, false, false, true, &ctx).expect("import"); let db = oxidebbs_db::OxideDb::open_or_create(&ctx.config.database.path).expect("open db"); let door = find_door_by_key(db.db(), "sample-door") .expect("find") .expect("imported door"); assert!(!door.enabled); - assert_eq!(door.working_dir, "sample"); + assert_eq!( + door.working_dir, + doors_root + .canonicalize() + .expect("doors root") + .join("sample") + .to_string_lossy() + .to_string() + ); assert_eq!(list_door_definitions(db.db()).expect("list").len(), 1); let target_install = doors_root.join("sample"); @@ -2489,6 +2880,39 @@ timeout_seconds = 120 cleanup_temp_dir(&temp); } + #[test] + fn run_door_package_import_dry_run_does_not_create_database_or_copy_files() { + let temp = test_temp_dir(); + let package_path = temp.join("dry-run.oxdoor"); + let doors_root = temp.join("doors"); + let runtime = temp.join("runtime"); + let mut config = test_config_with_doors_root(&doors_root, &runtime); + config.database.path = temp.join("database.ddb"); + fs::create_dir_all(&doors_root).expect("doors root"); + + write_dry_run_fixture( + &package_path, + &build_dry_run_manifest("freeware", "full", "DOOR.SYS", "local:dosemu2"), + &[("readme.txt", b"hello\n")], + true, + false, + None, + ) + .expect("write package"); + + let ctx = crate::sysop_cli::AppContext { + config_path: temp.join("oxidebbs.toml"), + config, + json: false, + }; + run_door_package_import(package_path, true, false, false, false, &ctx) + .expect("dry-run import"); + + assert!(!ctx.config.database.path.exists()); + assert!(!doors_root.join("sample").exists()); + cleanup_temp_dir(&temp); + } + #[test] fn run_door_package_import_enables_when_enable_flag_set() { let temp = test_temp_dir(); @@ -2515,7 +2939,7 @@ timeout_seconds = 120 config, json: false, }; - run_door_package_import(package_path, false, true, true, &ctx) + run_door_package_import(package_path, false, false, true, true, &ctx) .expect("import with --enable"); let db = oxidebbs_db::OxideDb::open_or_create(&ctx.config.database.path).expect("open db"); @@ -2572,8 +2996,8 @@ timeout_seconds = 120 config, json: false, }; - let error = - run_door_package_import(package_path, false, false, true, &ctx).expect_err("existing door"); + let error = run_door_package_import(package_path, false, false, false, true, &ctx) + .expect_err("existing door"); assert!(error.to_string().contains("already exists")); assert!(!doors_root.join("sample").exists()); @@ -2582,6 +3006,78 @@ timeout_seconds = 120 cleanup_temp_dir(&temp); } + #[test] + fn run_door_package_import_replaces_existing_definition_and_target_directory() { + let temp = test_temp_dir(); + let package_path = temp.join("replace.oxdoor"); + let doors_root = temp.join("doors"); + let runtime = temp.join("runtime"); + let mut config = test_config_with_doors_root(&doors_root, &runtime); + config.database.path = temp.join("database.ddb"); + fs::create_dir_all(doors_root.join("sample")).expect("target"); + fs::write(doors_root.join("sample/old.txt"), b"old").expect("old file"); + fs::create_dir_all(config.paths.runtime.clone()).expect("runtime"); + + write_dry_run_fixture( + &package_path, + &build_dry_run_manifest("freeware", "full", "DOOR.SYS", "local:dosemu2"), + &[("readme.txt", b"hello\n")], + true, + false, + None, + ) + .expect("write package"); + + let db = oxidebbs_db::OxideDb::open_or_create(&config.database.path).expect("open db"); + insert_door_definition( + db.db(), + &DoorDefinitionRecord { + id: "00000000-0000-4000-8000-000000000001".to_string(), + key: "sample-door".to_string(), + name: "Old Sample".to_string(), + runner: "dosemu2".to_string(), + working_dir: "old".to_string(), + command: "OLD.BAT".to_string(), + drop_file: "DOOR.SYS".to_string(), + exclusive: false, + time_limit_minutes: 1, + enabled: true, + min_security_level: 0, + }, + ) + .expect("insert existing door"); + drop(db); + + let ctx = crate::sysop_cli::AppContext { + config_path: temp.join("oxidebbs.toml"), + config, + json: false, + }; + run_door_package_import(package_path, false, true, false, true, &ctx) + .expect("replace import"); + + let db = oxidebbs_db::OxideDb::open_or_create(&ctx.config.database.path).expect("open db"); + let door = find_door_by_key(db.db(), "sample-door") + .expect("find") + .expect("door"); + assert_eq!(door.id, "00000000-0000-4000-8000-000000000001"); + assert_eq!(door.name, "Sample Door"); + assert_eq!( + door.working_dir, + doors_root + .canonicalize() + .expect("doors root") + .join("sample") + .to_string_lossy() + .to_string() + ); + assert!(!door.enabled); + assert_eq!(list_door_definitions(db.db()).expect("list").len(), 1); + assert!(doors_root.join("sample/readme.txt").is_file()); + assert!(!doors_root.join("sample/old.txt").exists()); + cleanup_temp_dir(&temp); + } + #[test] fn run_door_package_import_rejects_checksum_mismatch_without_writing() { let temp = test_temp_dir(); @@ -2594,7 +3090,7 @@ timeout_seconds = 120 fs::create_dir_all(config.paths.runtime.clone()).expect("runtime"); let mut overrides = HashMap::new(); - overrides.insert("files/readme.txt".to_string(), "ff".repeat(32) + "11"); + overrides.insert("files/readme.txt".to_string(), "00".repeat(32)); write_dry_run_fixture( &package_path, &build_dry_run_manifest("freeware", "full", "DOOR.SYS", "local:dosemu2"), @@ -2610,8 +3106,8 @@ timeout_seconds = 120 config, json: false, }; - let error = - run_door_package_import(package_path, false, false, true, &ctx).expect_err("checksum mismatch"); + let error = run_door_package_import(package_path, false, false, false, true, &ctx) + .expect_err("checksum mismatch"); assert!(error.to_string().contains("checksum mismatch")); assert!(!doors_root.join("sample").exists()); cleanup_temp_dir(&temp); @@ -2643,7 +3139,7 @@ timeout_seconds = 120 config, json: false, }; - let error = run_door_package_import(package_path, false, false, true, &ctx) + let error = run_door_package_import(package_path, false, false, false, true, &ctx) .expect_err("path traversal"); assert!(error.to_string().contains("traversal")); assert!(!doors_root.join("sample").exists()); diff --git a/crates/oxidebbs-server/src/sysop_cli.rs b/crates/oxidebbs-server/src/sysop_cli.rs index 140b575..be8e877 100644 --- a/crates/oxidebbs-server/src/sysop_cli.rs +++ b/crates/oxidebbs-server/src/sysop_cli.rs @@ -48,7 +48,7 @@ pub(crate) enum CliError { Database(#[from] oxidebbs_db::DbError), #[error(transparent)] - Door(#[from] oxidebbs_door::DoorError), + Door(Box), #[error(transparent)] Json(#[from] serde_json::Error), @@ -63,6 +63,12 @@ pub(crate) enum CliError { Serve(#[from] serve::ServeError), } +impl From for CliError { + fn from(error: oxidebbs_door::DoorError) -> Self { + Self::Door(Box::new(error)) + } +} + #[derive(Parser)] #[command( name = "oxidebbs", diff --git a/crates/oxidebbs-sysop/src/lib.rs b/crates/oxidebbs-sysop/src/lib.rs index d93b471..7a4aee2 100644 --- a/crates/oxidebbs-sysop/src/lib.rs +++ b/crates/oxidebbs-sysop/src/lib.rs @@ -31,7 +31,7 @@ pub enum SysopError { Database(#[from] oxidebbs_db::DbError), #[error("door config error: {0}")] - DoorConfig(#[from] DoorError), + DoorConfig(#[source] Box), #[error("I/O error: {0}")] Io(#[from] std::io::Error), @@ -43,6 +43,12 @@ pub enum SysopError { Message(String), } +impl From for SysopError { + fn from(error: DoorError) -> Self { + Self::DoorConfig(Box::new(error)) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum AdminCommand { ListUsers, diff --git a/crates/oxidebbs-transfer/src/zmodem.rs b/crates/oxidebbs-transfer/src/zmodem.rs index f67e425..64afcaa 100644 --- a/crates/oxidebbs-transfer/src/zmodem.rs +++ b/crates/oxidebbs-transfer/src/zmodem.rs @@ -1260,7 +1260,7 @@ mod tests { #[test] fn zdle_escape_does_not_escape_normal_bytes() { - let data = [b'A', b'B', b'C']; + let data = *b"ABC"; let escaped = zdle_escape(&data, false); assert_eq!(escaped, vec![b'A', b'B', b'C']); } diff --git a/design/OXDOOR_FORMAT_V1.md b/design/OXDOOR_FORMAT_V1.md index ecd5109..713343d 100644 --- a/design/OXDOOR_FORMAT_V1.md +++ b/design/OXDOOR_FORMAT_V1.md @@ -14,14 +14,14 @@ oxide-door.toml checksums.sha256 files/ docs/ optional -artifacts/ optional +tests/ optional ``` `oxide-door.toml` must include: - `package.format = "oxide-door-package-v1"` - `package.kind = "full"` -All files under `files/`, `docs/`, and `artifacts/` should be listed in `checksums.sha256` using: +All files under `files/`, `docs/`, and `tests/` should be listed in `checksums.sha256` using: ```text @@ -95,7 +95,7 @@ Optional top-level entries: ```text docs/ -artifacts/ +tests/ ``` Recommended layout: @@ -111,8 +111,8 @@ doradvnt.oxdoor ├── docs/ │ ├── README.TXT │ └── SYSOP.DOC -└── artifacts/ - └── inspect-report.md +└── tests/ + └── smoke.md ``` ## 4. Package kinds @@ -358,7 +358,7 @@ Rules: ## 7. `checksums.sha256` -`checksums.sha256` contains SHA-256 hashes for files packaged under `files/`, `docs/`, and `artifacts/` as appropriate. +`checksums.sha256` contains SHA-256 hashes for files packaged under `files/`, `docs/`, and `tests/` as appropriate. Format: @@ -433,6 +433,8 @@ Command goal: ```bash oxidebbs-server doors package import +oxidebbs-server doors package import --replace +oxidebbs-server doors package import --enable ``` Expected behavior: @@ -440,13 +442,15 @@ Expected behavior: - Perform all dry-run validations. - Copy `files/` into the configured door root under the package door working directory. - Create or update the OxideBBS door definition through existing door service code paths. -- Default to disabled unless an explicit `--enable` flag is provided and `enabled_after_import = true` is honored by policy. +- Default to disabled unless an explicit sysop `--enable` flag is provided. A + package `enabled_after_import` request is advisory and must not enable a door + by itself. - Run the same validation used by `doors check` when feasible. - Print suggested next commands. Conflict behavior: -- If a door definition already exists, fail unless `--replace` or a future `--update` flag is provided. +- If a door definition already exists, fail unless `--replace` is provided. - If a target directory already exists, fail unless `--replace` is provided. - `--replace` should be conservative and should avoid deleting unknown existing files unless explicitly designed and tested. diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index a714fc5..ec96cb5 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -62,6 +62,7 @@ export default defineConfig({ { text: 'Architecture', link: '/project/architecture' }, { text: 'Versioning', link: '/project/versioning' }, { text: 'Release Process', link: '/release' }, + { text: 'OxDoor Format', link: '/OXDOOR_FORMAT_V1' }, { text: 'Changelog', link: '/about/changelog' } ] } @@ -75,6 +76,7 @@ export default defineConfig({ { text: 'Docker Deployment', link: '/project/docker' }, { text: 'Release Binaries', link: '/project/release-binaries' }, { text: 'Release Process', link: '/release' }, + { text: 'OxDoor Format', link: '/OXDOOR_FORMAT_V1' }, { text: 'Setup Wizard', link: '/project/setup' }, { text: 'DOSEMU2 On Fedora', link: '/project/dosemu2-fedora' }, { text: 'Architecture', link: '/project/architecture' }, diff --git a/docs/OXDOOR_FORMAT_V1.md b/docs/OXDOOR_FORMAT_V1.md index 8e81e9b..c3b929f 100644 --- a/docs/OXDOOR_FORMAT_V1.md +++ b/docs/OXDOOR_FORMAT_V1.md @@ -9,12 +9,28 @@ In short: - `package.format` must be `oxide-door-package-v1`. - `package.kind` must be `full` for now. - Supported payload roots are `files/` (required for full packages), `docs/`, and - `artifacts/` (optional). + `tests/` (optional). - Supported drop files are `DOOR.SYS`, `DORINFO1.DEF`, `CHAIN.TXT`, `DOORFILE.SR`, `PCBOARD.SYS`, `CALLINFO.BBS`. +- Imports are declarative only. OxideBBS does not run package-provided + `postinstall.sh`, `install.bat`, keygens, downloaded scripts, or shell hooks. +- Imported doors default to disabled unless the sysop passes `--enable`. Inspect packages with: ```bash oxidebbs-server doors package inspect path/to/package.oxdoor +oxidebbs-server doors package import path/to/package.oxdoor --dry-run +oxidebbs-server doors package import path/to/package.oxdoor +oxidebbs-server doors package import path/to/package.oxdoor --replace ``` + +`inspect` validates metadata, checksums, and archive path safety without +installing files or writing DecentDB. `import --dry-run` prints the planned file +copies and door definition changes without writing files, creating a database, or +enabling the door. Real import copies only `files/` payloads under the configured +door root and creates or updates the door definition; existing target directories +or door definitions require `--replace`. + +OxideBBS does not bundle third-party copyrighted, shareware, or abandonware DOS +doors. Operators are responsible for using door binaries they have rights to run. diff --git a/docs/project/doors.md b/docs/project/doors.md index eb4c3cd..0013606 100644 --- a/docs/project/doors.md +++ b/docs/project/doors.md @@ -98,14 +98,29 @@ Door run history is visible through: ```bash oxidebbs-server doors package inspect sample.oxdoor oxidebbs-server doors package import sample.oxdoor --dry-run +oxidebbs-server doors package import sample.oxdoor +oxidebbs-server doors package import sample.oxdoor --replace oxidebbs-server doors runs list oxidebbs-server doors runs show oxidebbs-server doors cleanup ``` +`.oxdoor` packages are ZIP archives with `oxide-door.toml`, +`checksums.sha256`, and a `files/` payload root. Optional roots are `docs/` and +`tests/`. `inspect` validates metadata, checksums, and path safety without +installing anything. `import --dry-run` prints the target directory, planned file +copies, and door definition changes without writing files or DecentDB. Real +import copies only `files/` under `paths.doors`, creates or updates the door +definition, and leaves the door disabled unless `--enable` is passed. Existing +target directories or door definitions require `--replace`. + +Package import does not run package-provided installer scripts, setup batches, +keygens, downloaded commands, shell hooks, or menu rewrites. Menu metadata is a +hint only; enabled configured doors are what the caller Doors menu exposes. + `doors cleanup` removes leftover `node-*` door runtime directories under `paths.runtime`. It does not rewrite persisted door-run history. -OxideBBS does not bundle copyrighted or abandonware DOS doors. Operators provide -their own door binaries or use the project-owned Oxide Door Check fixture for -validation. +OxideBBS does not bundle third-party copyrighted, shareware, or abandonware DOS +doors. Operators provide their own door binaries or use the project-owned Oxide +Door Check fixture for validation. diff --git a/docs/project/sysop-cli.md b/docs/project/sysop-cli.md index 701d72f..dafb307 100644 --- a/docs/project/sysop-cli.md +++ b/docs/project/sysop-cli.md @@ -446,6 +446,7 @@ Door management: - `oxidebbs-server doors disable ` - `oxidebbs-server doors package inspect ` - `oxidebbs-server doors package import --dry-run` +- `oxidebbs-server doors package import [--replace] [--enable]` - `oxidebbs-server doors test --user sysop --dry-run` - `oxidebbs-server doors dropfile --user sysop --node 1 --format DORINFO1.DEF` - `oxidebbs-server doors runs list` @@ -459,7 +460,15 @@ Meaning: file safety constraints, then prints a read-only summary. - `doors package import --dry-run` validates packages for import, computes the target install directory and door definition, and prints a planned import report with - conflicts, warnings, and follow-up commands. + planned file copies, conflicts, warnings, and follow-up commands. It does not + copy files, create or migrate DecentDB, write door definitions, enable doors, + or change menus. +- `doors package import` copies only package `files/` payloads under the + configured door root and creates the door definition disabled by default. + Existing target directories or door definitions require `--replace`; `--enable` + is the explicit opt-in to make the imported door caller-selectable. +- `.oxdoor` v1 is declarative only. Import never runs package-provided installer + scripts, keygens, setup batches, downloaded commands, or shell hooks. - `doors dropfile --format` supports `DOOR.SYS`, `DORINFO1.DEF`, `CHAIN.TXT`, `DOORFILE.SR`, `PCBOARD.SYS`, and `CALLINFO.BBS`. - Live interactive DOS door testing requires a caller session. Start `serve`, @@ -467,6 +476,8 @@ Meaning: - The bundled test door is `oxide-check` (`OXIDECHK.EXE`) for validating the DOSEMU2 serial runtime bridge. - Enabled configured doors are the only ones selectable by live caller menu. +- OxideBBS does not bundle third-party copyrighted, shareware, or abandonware DOS + doors; sysops provide door binaries they have rights to run. - Live launch writes drop files in the node runtime directory, tracks `door_started`/`door_finished`/`door_timed_out` events, and returns the caller to the menu on completion or timeout. From d3b68546c32e43b402afc805000306f561055355 Mon Sep 17 00:00:00 2001 From: Steven Hildreth Date: Mon, 13 Jul 2026 16:01:11 -0500 Subject: [PATCH 5/9] feat(telnet): refactor telnet module and improve transport error handling --- crates/oxidebbs-core/src/lib.rs | 2 + crates/oxidebbs-core/tests/constants_test.rs | 2 +- crates/oxidebbs-telnet/src/lib.rs | 8 +- crates/oxidebbs-telnet/src/telnet.rs | 561 ------------------- crates/oxidebbs-telnet/src/telnet/mod.rs | 457 +++++++++++++++ crates/oxidebbs-telnet/src/transport.rs | 5 +- crates/oxidebbs-transfer/src/zmodem.rs | 49 ++ 7 files changed, 518 insertions(+), 566 deletions(-) delete mode 100644 crates/oxidebbs-telnet/src/telnet.rs create mode 100644 crates/oxidebbs-telnet/src/telnet/mod.rs diff --git a/crates/oxidebbs-core/src/lib.rs b/crates/oxidebbs-core/src/lib.rs index 74b9473..78334f6 100644 --- a/crates/oxidebbs-core/src/lib.rs +++ b/crates/oxidebbs-core/src/lib.rs @@ -15,3 +15,5 @@ pub use network::{ NetworkConfigError, NetworkLink, NetworkMessageEnvelope, NetworkMessageKind, NetworkProfile, PacketBoundary, PacketDirection, QueueState, TransportSecurity, }; +pub mod constants; +pub use constants::*; diff --git a/crates/oxidebbs-core/tests/constants_test.rs b/crates/oxidebbs-core/tests/constants_test.rs index 0e8d4a4..22819d1 100644 --- a/crates/oxidebbs-core/tests/constants_test.rs +++ b/crates/oxidebbs-core/tests/constants_test.rs @@ -1,6 +1,6 @@ #[cfg(test)] mod tests { - use super::*; + use oxidebbs_core::*; #[test] fn constants_have_expected_values() { diff --git a/crates/oxidebbs-telnet/src/lib.rs b/crates/oxidebbs-telnet/src/lib.rs index ead5745..94ad4b8 100644 --- a/crates/oxidebbs-telnet/src/lib.rs +++ b/crates/oxidebbs-telnet/src/lib.rs @@ -6,9 +6,11 @@ pub use serial::{ SerialFlowControl, SerialHandle, SerialLoopback, SerialOpenError, SerialParity, SerialPortConfig, SerialTransport, }; + pub use telnet::{ - TELOPT_BINARY, TELOPT_ECHO, TELOPT_NAWS, TELOPT_SUPPRESS_GO_AHEAD, TELOPT_TERMINAL_TYPE, - TelnetCommand, TelnetEvent, TelnetLifecycleHooks, TelnetOptionPolicy, TelnetParser, - TelnetSession, + DO, DONT, IAC, SB, SE, TELOPT_ECHO, TELOPT_NAWS, TELOPT_SUPPRESS_GO_AHEAD, + TELOPT_TERMINAL_TYPE, TELOPT_TTYPE_IS, TELOPT_TTYPE_SEND, TelnetCommand, TelnetEvent, + TelnetParser, TelnetSession, WILL, WONT, }; + pub use transport::{LoopbackTransport, TcpTransport, Transport, TransportError}; diff --git a/crates/oxidebbs-telnet/src/telnet.rs b/crates/oxidebbs-telnet/src/telnet.rs deleted file mode 100644 index 416134c..0000000 --- a/crates/oxidebbs-telnet/src/telnet.rs +++ /dev/null @@ -1,561 +0,0 @@ -use std::sync::Arc; - -use crate::transport::TransportError; - -use crate::transport::Transport; - -type LifecycleHook = Arc; - -pub const IAC: u8 = 0xFF; -pub const SE: u8 = 0xF0; -pub const DONT: u8 = 0xFE; -pub const DO: u8 = 0xFD; -pub const WONT: u8 = 0xFC; -pub const WILL: u8 = 0xFB; -pub const SB: u8 = 0xFA; - -pub const TELOPT_BINARY: u8 = 0; -pub const TELOPT_ECHO: u8 = 1; -pub const TELOPT_SUPPRESS_GO_AHEAD: u8 = 3; -pub const TELOPT_TERMINAL_TYPE: u8 = 24; -pub const TELOPT_NAWS: u8 = 31; -pub const TELOPT_TTYPE_SEND: u8 = 1; -pub const TELOPT_TTYPE_IS: u8 = 0; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TelnetCommand { - Will, - Wont, - Do, - Dont, -} - -impl TelnetCommand { - fn as_u8(self) -> u8 { - match self { - Self::Will => WILL, - Self::Wont => WONT, - Self::Do => DO, - Self::Dont => DONT, - } - } - - fn from_u8(byte: u8) -> Option { - match byte { - WILL => Some(Self::Will), - WONT => Some(Self::Wont), - DO => Some(Self::Do), - DONT => Some(Self::Dont), - _ => None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum TelnetEvent { - Data(u8), - Negotiation { - command: TelnetCommand, - option: u8, - accepted: bool, - }, - TerminalType(Vec), - TerminalTypeRequest, - WindowSize { - columns: u16, - rows: u16, - }, - Subnegotiation { - option: u8, - data: Vec, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TelnetOptionPolicy { - pub accept_echo: bool, - pub accept_suppress_go_ahead: bool, - pub accept_terminal_type: bool, - pub accept_naws: bool, - pub terminal_type: Vec, -} - -impl Default for TelnetOptionPolicy { - fn default() -> Self { - Self { - accept_echo: true, - accept_suppress_go_ahead: true, - accept_terminal_type: true, - accept_naws: true, - terminal_type: b"VT100".to_vec(), - } - } -} - -impl TelnetOptionPolicy { - fn accepts(&self, option: u8) -> bool { - match option { - TELOPT_ECHO => self.accept_echo, - TELOPT_SUPPRESS_GO_AHEAD => self.accept_suppress_go_ahead, - TELOPT_TERMINAL_TYPE => self.accept_terminal_type, - TELOPT_NAWS => self.accept_naws, - _ => false, - } - } -} - -#[derive(Default)] -pub struct TelnetLifecycleHooks { - pub on_connect: Option, - pub on_disconnect: Option, -} - -impl TelnetLifecycleHooks { - pub fn with_connect_hook(mut self, hook: F) -> Self - where - F: Fn(&str) + Send + Sync + 'static, - { - self.on_connect = Some(Arc::new(hook)); - self - } - - pub fn with_disconnect_hook(mut self, hook: F) -> Self - where - F: Fn(&str) + Send + Sync + 'static, - { - self.on_disconnect = Some(Arc::new(hook)); - self - } -} - -#[derive(Default)] -enum ParserState { - #[default] - Data, - Iac, - Negotiation, - SubnegotiationOption, - SubnegotiationData, -} - -#[derive(Default)] -pub struct TelnetParser { - state: ParserState, - pending_command: Option, - pending_subnegotiation_option: Option, - subnegotiation_data: Vec, - subnegotiation_escape: bool, - policy: TelnetOptionPolicy, -} - -impl TelnetParser { - pub fn with_policy(policy: TelnetOptionPolicy) -> Self { - Self { - policy, - state: ParserState::Data, - ..Default::default() - } - } - - pub fn policy(&self) -> &TelnetOptionPolicy { - &self.policy - } - - pub fn feed(&mut self, byte: u8, reply: &mut Vec) -> Option { - match self.state { - ParserState::Data => { - if byte == IAC { - self.state = ParserState::Iac; - None - } else { - Some(TelnetEvent::Data(byte)) - } - } - ParserState::Iac => { - self.state = match byte { - IAC => { - self.state = ParserState::Data; - return Some(TelnetEvent::Data(IAC)); - } - SB => ParserState::SubnegotiationOption, - DONT | DO | WILL | WONT => { - self.pending_command = TelnetCommand::from_u8(byte); - ParserState::Negotiation - } - _ => { - // Ignore unhandled command bytes and continue. - ParserState::Data - } - }; - - None - } - ParserState::Negotiation => { - let command = self - .pending_command - .take() - .expect("negotiation state without pending command"); - let accepted = self.policy.accepts(byte); - self.state = ParserState::Data; - self.negotiate_reply(command, byte, accepted, reply); - Some(TelnetEvent::Negotiation { - command, - option: byte, - accepted, - }) - } - ParserState::SubnegotiationOption => { - self.pending_subnegotiation_option = Some(byte); - self.subnegotiation_data.clear(); - self.subnegotiation_escape = false; - self.state = ParserState::SubnegotiationData; - None - } - ParserState::SubnegotiationData => match self.subnegotiation_escape { - true => { - self.subnegotiation_escape = false; - if byte == IAC { - self.subnegotiation_data.push(IAC); - None - } else if byte == SE { - self.state = ParserState::Data; - self.take_subnegotiation_event(reply) - } else { - self.subnegotiation_data.push(IAC); - self.subnegotiation_data.push(byte); - None - } - } - false => { - if byte == IAC { - self.subnegotiation_escape = true; - None - } else { - self.subnegotiation_data.push(byte); - None - } - } - }, - } - } - - fn negotiate_reply( - &self, - command: TelnetCommand, - option: u8, - accepted: bool, - reply: &mut Vec, - ) { - let should_reply = accepted; - let response = match command { - TelnetCommand::Will if should_reply => TelnetCommand::Do, - TelnetCommand::Will => TelnetCommand::Dont, - TelnetCommand::Do if should_reply => TelnetCommand::Will, - TelnetCommand::Do => TelnetCommand::Wont, - _ => { - return; - } - }; - - reply.extend_from_slice(&[IAC, response.as_u8(), option]); - } - - fn take_subnegotiation_event(&mut self, reply: &mut Vec) -> Option { - let option = match self.pending_subnegotiation_option.take() { - Some(option) => option, - None => { - return None; - } - }; - let data = std::mem::take(&mut self.subnegotiation_data); - - Some(match option { - TELOPT_TERMINAL_TYPE => self.resolve_terminal_type_subnegotiation(data, reply), - TELOPT_NAWS => self.parse_window_size(data), - _ => TelnetEvent::Subnegotiation { option, data }, - }) - } - - fn resolve_terminal_type_subnegotiation( - &self, - mut data: Vec, - reply: &mut Vec, - ) -> TelnetEvent { - if data.is_empty() { - return TelnetEvent::Subnegotiation { - option: TELOPT_TERMINAL_TYPE, - data, - }; - } - - let command = data.remove(0); - if command == TELOPT_TTYPE_SEND && self.policy.accept_terminal_type { - reply.extend_from_slice(&[IAC, SB, TELOPT_TERMINAL_TYPE, TELOPT_TTYPE_IS]); - reply.extend_from_slice(&self.policy.terminal_type); - reply.extend_from_slice(&[IAC, SE]); - TelnetEvent::TerminalTypeRequest - } else if command == TELOPT_TTYPE_IS { - TelnetEvent::TerminalType(data) - } else { - let mut rebuilt = Vec::with_capacity(1 + data.len()); - rebuilt.push(command); - rebuilt.extend_from_slice(&data); - TelnetEvent::Subnegotiation { - option: TELOPT_TERMINAL_TYPE, - data: rebuilt, - } - } - } - - fn parse_window_size(&self, data: Vec) -> TelnetEvent { - if data.len() == 4 { - let columns = u16::from_be_bytes([data[0], data[1]]); - let rows = u16::from_be_bytes([data[2], data[3]]); - TelnetEvent::WindowSize { columns, rows } - } else { - TelnetEvent::Subnegotiation { - option: TELOPT_NAWS, - data, - } - } - } -} - -pub struct TelnetSession { - transport: T, - parser: TelnetParser, - session_id: String, - hooks: TelnetLifecycleHooks, - connected: bool, -} - -impl TelnetSession { - pub fn new(transport: T, session_id: impl Into) -> Self { - Self::with_parser( - transport, - session_id, - TelnetParser::with_policy(TelnetOptionPolicy::default()), - TelnetLifecycleHooks::default(), - ) - } - - pub fn with_parser( - transport: T, - session_id: impl Into, - parser: TelnetParser, - hooks: TelnetLifecycleHooks, - ) -> Self { - Self { - transport, - parser, - session_id: session_id.into(), - hooks, - connected: false, - } - } - - pub async fn read(&mut self) -> Result, TransportError> { - self.maybe_connect(); - let byte = self.transport.read_byte().await?; - match byte { - None => { - self.maybe_disconnect(); - Ok(None) - } - Some(byte) => { - let mut reply = Vec::new(); - let event = self.parser.feed(byte, &mut reply); - if !reply.is_empty() { - self.transport.write_all(&reply).await?; - } - Ok(event) - } - } - } - - fn maybe_connect(&mut self) { - if self.connected { - return; - } - self.connected = true; - if let Some(hook) = &self.hooks.on_connect { - (hook)(self.session_id.as_str()); - } - } - - fn maybe_disconnect(&mut self) { - if !self.connected { - return; - } - self.connected = false; - if let Some(hook) = &self.hooks.on_disconnect { - (hook)(self.session_id.as_str()); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::transport::LoopbackTransport; - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; - - #[tokio::test] - async fn parse_escaped_iac_in_data() { - let mut parser = TelnetParser::with_policy(TelnetOptionPolicy::default()); - let mut out = Vec::new(); - - let mut events = Vec::new(); - events.push(parser.feed(b'H', &mut out).expect("data event")); - assert!(parser.feed(IAC, &mut out).is_none()); - events.push(parser.feed(IAC, &mut out).expect("escaped iac")); - events.push(parser.feed(b'I', &mut out).expect("data event")); - - assert_eq!( - events, - vec![ - TelnetEvent::Data(b'H'), - TelnetEvent::Data(IAC), - TelnetEvent::Data(b'I'), - ] - ); - assert!(out.is_empty()); - } - - #[tokio::test] - async fn negotiate_iac_will_do_sequences() { - let mut parser = TelnetParser::with_policy(TelnetOptionPolicy::default()); - let mut out = Vec::new(); - - assert_eq!(parser.feed(IAC, &mut out), None, "enter command state"); - assert_eq!(parser.feed(DO, &mut out), None, "capture negotiation verb"); - let event = parser - .feed(TELOPT_SUPPRESS_GO_AHEAD, &mut out) - .expect("negotiation event"); - assert_eq!( - out, - vec![IAC, WILL, TELOPT_SUPPRESS_GO_AHEAD], - "server should accept by default" - ); - assert_eq!( - event, - TelnetEvent::Negotiation { - command: TelnetCommand::Do, - option: TELOPT_SUPPRESS_GO_AHEAD, - accepted: true, - } - ); - - out.clear(); - assert_eq!(parser.feed(IAC, &mut out), None); - assert_eq!(parser.feed(WILL, &mut out), None); - let echo_event = parser - .feed(TELOPT_ECHO, &mut out) - .expect("echo negotiation"); - assert_eq!(out, vec![IAC, DO, TELOPT_ECHO]); - assert_eq!( - echo_event, - TelnetEvent::Negotiation { - command: TelnetCommand::Will, - option: TELOPT_ECHO, - accepted: true, - } - ); - } - - #[tokio::test] - async fn terminal_type_subnegotiation_emits_request_and_replies() { - let mut parser = TelnetParser::with_policy(TelnetOptionPolicy::default()); - let mut out = Vec::new(); - - let sequence = [IAC, SB, TELOPT_TERMINAL_TYPE, TELOPT_TTYPE_SEND, IAC, SE]; - - let mut events = Vec::new(); - for byte in sequence { - if let Some(event) = parser.feed(byte, &mut out) { - events.push(event); - } - } - - assert_eq!( - events, - vec![TelnetEvent::TerminalTypeRequest], - "terminal type request should surface as event" - ); - assert_eq!( - out, - vec![ - IAC, - SB, - TELOPT_TERMINAL_TYPE, - TELOPT_TTYPE_IS, - b'V', - b'T', - b'1', - b'0', - b'0', - IAC, - SE, - ], - "server should send terminal type response" - ); - } - - #[tokio::test] - async fn naws_subnegotiation_parsed() { - let mut parser = TelnetParser::with_policy(TelnetOptionPolicy::default()); - let mut out = Vec::new(); - let sequence = [IAC, SB, TELOPT_NAWS, 0x01, 0x2C, 0x00, 0x50, IAC, SE]; - - let mut event = None; - for byte in sequence { - if let Some(e) = parser.feed(byte, &mut out) { - event = Some(e); - } - } - - assert!(out.is_empty()); - assert_eq!( - event, - Some(TelnetEvent::WindowSize { - columns: 300, - rows: 80 - }) - ); - } - - #[tokio::test] - async fn session_hooks_fire_on_connect_and_disconnect() { - let (server, client) = LoopbackTransport::new(); - let connect_count = Arc::new(AtomicUsize::new(0)); - let disconnect_count = Arc::new(AtomicUsize::new(0)); - - let hooks = TelnetLifecycleHooks::default() - .with_connect_hook({ - let connect_count = connect_count.clone(); - move |_| { - connect_count.fetch_add(1, Ordering::SeqCst); - } - }) - .with_disconnect_hook({ - let disconnect_count = disconnect_count.clone(); - move |_| { - disconnect_count.fetch_add(1, Ordering::SeqCst); - } - }); - - let mut session = - TelnetSession::with_parser(server, "node-1", TelnetParser::default(), hooks); - client.write_bytes(b"X").expect("write test byte"); - let event = session.read().await.expect("session read"); - assert_eq!(event, Some(TelnetEvent::Data(b'X'))); - - drop(client); - let eof = session.read().await.expect("session eof"); - assert_eq!(eof, None); - - assert_eq!(connect_count.load(Ordering::SeqCst), 1); - assert_eq!(disconnect_count.load(Ordering::SeqCst), 1); - } -} diff --git a/crates/oxidebbs-telnet/src/telnet/mod.rs b/crates/oxidebbs-telnet/src/telnet/mod.rs new file mode 100644 index 0000000..e616a8a --- /dev/null +++ b/crates/oxidebbs-telnet/src/telnet/mod.rs @@ -0,0 +1,457 @@ +use super::transport::{Transport, TransportError}; +use std::fmt; +use thiserror::Error; + +pub const IAC: u8 = 255; +pub const DO: u8 = 253; +pub const DONT: u8 = 254; +pub const WILL: u8 = 251; +pub const WONT: u8 = 252; +pub const SB: u8 = 250; +pub const SE: u8 = 240; + +pub const TELOPT_ECHO: u8 = 1; +pub const TELOPT_SUPPRESS_GO_AHEAD: u8 = 3; +pub const TELOPT_NAWS: u8 = 31; +pub const TELOPT_TERMINAL_TYPE: u8 = 24; +pub const TELOPT_TTYPE_IS: u8 = 0; +pub const TELOPT_TTYPE_SEND: u8 = 1; + +#[derive(Debug, Error)] +pub enum TelnetError { + #[error("transport error: {0}")] + Transport(#[from] TransportError), + #[error("incomplete IAC sequence")] + Incomplete, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TelnetCommand { + Will, + Wont, + Do, + Dont, + Sb, + Se, + Iac, +} + +impl From for TelnetCommand { + fn from(b: u8) -> Self { + match b { + WILL => TelnetCommand::Will, + WONT => TelnetCommand::Wont, + DO => TelnetCommand::Do, + DONT => TelnetCommand::Dont, + SB => TelnetCommand::Sb, + SE => TelnetCommand::Se, + _ => TelnetCommand::Iac, // treat unknown as IAC escape + } + } +} + +/// Event produced by the incremental telnet byte parser. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TelnetEvent { + /// A raw data byte destined for the caller. + Data(u8), + /// A negotiated window size (NAWS subnegotiation). + WindowSize { columns: u16, rows: u16 }, + /// A telnet option negotiation with the resulting acceptance. + Negotiation { + command: TelnetCommand, + option: u8, + accepted: bool, + }, + /// A TERMINAL-TYPE value reported by the client. + TerminalType(Vec), + /// The server requested the client's terminal type. + TerminalTypeRequest, + /// A generic subnegotiation for an option the parser does not special-case. + Subnegotiation { option: u8, data: Vec }, +} + +/// Incremental, async-free telnet protocol parser. +/// +/// Feed bytes one at a time via [`TelnetParser::feed`]. When a negotiation +/// requires a response, the reply bytes are appended to the provided buffer. +/// When a full protocol unit is recognized, a [`TelnetEvent`] is returned. +#[derive(Default)] +pub struct TelnetParser { + state: ParserState, + sb_option: Option, + sb_data: Vec, +} + +#[derive(Default)] +enum ParserState { + #[default] + Data, + Iac, + WillOpt, + WontOpt, + DoOpt, + DontOpt, + SbOption, + SbData, + SbIac, +} + +impl TelnetParser { + pub fn new() -> Self { + Self::default() + } + + /// Feed a single byte. Returns an event when one is completed. + /// Negotiation reply bytes are appended to `reply`. + pub fn feed(&mut self, byte: u8, reply: &mut Vec) -> Option { + match self.state { + ParserState::Data => { + if byte == IAC { + self.state = ParserState::Iac; + None + } else { + Some(TelnetEvent::Data(byte)) + } + } + ParserState::Iac => match byte { + IAC => { + self.state = ParserState::Data; + Some(TelnetEvent::Data(IAC)) + } + WILL => { + self.state = ParserState::WillOpt; + None + } + WONT => { + self.state = ParserState::WontOpt; + None + } + DO => { + self.state = ParserState::DoOpt; + None + } + DONT => { + self.state = ParserState::DontOpt; + None + } + SB => { + self.state = ParserState::SbOption; + self.sb_option = None; + self.sb_data.clear(); + None + } + SE => { + self.state = ParserState::Data; + None + } + _ => { + self.state = ParserState::Data; + None + } + }, + ParserState::WillOpt => { + let opt = byte; + self.state = ParserState::Data; + reply.extend_from_slice(&[IAC, DO, opt]); + Some(TelnetEvent::Negotiation { + command: TelnetCommand::Will, + option: opt, + accepted: true, + }) + } + ParserState::WontOpt => { + let opt = byte; + self.state = ParserState::Data; + reply.extend_from_slice(&[IAC, DONT, opt]); + Some(TelnetEvent::Negotiation { + command: TelnetCommand::Wont, + option: opt, + accepted: false, + }) + } + ParserState::DoOpt => { + let opt = byte; + self.state = ParserState::Data; + reply.extend_from_slice(&[IAC, WILL, opt]); + Some(TelnetEvent::Negotiation { + command: TelnetCommand::Do, + option: opt, + accepted: true, + }) + } + ParserState::DontOpt => { + let opt = byte; + self.state = ParserState::Data; + reply.extend_from_slice(&[IAC, WONT, opt]); + Some(TelnetEvent::Negotiation { + command: TelnetCommand::Dont, + option: opt, + accepted: false, + }) + } + ParserState::SbOption => { + self.sb_option = Some(byte); + self.state = ParserState::SbData; + None + } + ParserState::SbData => { + if byte == IAC { + self.state = ParserState::SbIac; + } else { + self.sb_data.push(byte); + } + None + } + ParserState::SbIac => match byte { + SE => { + let event = self.finish_subnegotiation(); + self.state = ParserState::Data; + event + } + IAC => { + self.sb_data.push(IAC); + self.state = ParserState::SbData; + None + } + _ => { + self.state = ParserState::Data; + None + } + }, + } + } + + fn finish_subnegotiation(&mut self) -> Option { + let option = self.sb_option?; + let data = std::mem::take(&mut self.sb_data); + if option == TELOPT_TERMINAL_TYPE && !data.is_empty() { + match data[0] { + TELOPT_TTYPE_IS => { + let value = data.into_iter().skip(1).collect(); + return Some(TelnetEvent::TerminalType(value)); + } + TELOPT_TTYPE_SEND => { + return Some(TelnetEvent::TerminalTypeRequest); + } + _ => {} + } + } + if option == TELOPT_NAWS && data.len() >= 4 { + let columns = u16::from_be_bytes([data[0], data[1]]); + let rows = u16::from_be_bytes([data[2], data[3]]); + return Some(TelnetEvent::WindowSize { columns, rows }); + } + Some(TelnetEvent::Subnegotiation { option, data }) + } +} + +pub struct TelnetSession { + transport: T, +} + +impl TelnetSession { + pub fn new(transport: T) -> Self { + Self { transport } + } + + // read one byte, handling IAC negotiations and returning raw data bytes. + pub async fn read_byte(&mut self) -> Result, TelnetError> { + loop { + let opt = self.transport.read_byte().await?; + let byte = match opt { + None => return Ok(None), + Some(b) => b, + }; + if byte == IAC { + // need next byte + let opt_next = self.transport.read_byte().await?; + let next = match opt_next { + None => return Err(TelnetError::Incomplete), + Some(b) => b, + }; + let cmd = TelnetCommand::from(next); + match cmd { + TelnetCommand::Iac => return Ok(Some(IAC)), + TelnetCommand::Will => { + let opt_code = self.read_option().await?; + self.send_will(opt_code).await?; + continue; + } + TelnetCommand::Wont => { + let opt_code = self.read_option().await?; + self.send_wont(opt_code).await?; + continue; + } + TelnetCommand::Do => { + let opt_code = self.read_option().await?; + self.send_do(opt_code).await?; + continue; + } + TelnetCommand::Dont => { + let opt_code = self.read_option().await?; + self.send_dont(opt_code).await?; + continue; + } + TelnetCommand::Sb => { + // consume until SE + loop { + let opt_sb = self.transport.read_byte().await?; + let b = match opt_sb { + None => return Err(TelnetError::Incomplete), + Some(b) => b, + }; + if b == SE { + break; + } + } + continue; + } + TelnetCommand::Se => { + // stray SE, ignore + continue; + } + } + } else { + return Ok(Some(byte)); + } + } + } + + async fn read_option(&mut self) -> Result { + match self.transport.read_byte().await? { + None => Err(TelnetError::Incomplete), + Some(b) => Ok(b), + } + } + + async fn send_will(&mut self, opt: u8) -> Result<(), TelnetError> { + let bytes = [IAC, DO, opt]; + self.transport.write_all(&bytes).await?; + Ok(()) + } + + async fn send_wont(&mut self, opt: u8) -> Result<(), TelnetError> { + let bytes = [IAC, DONT, opt]; + self.transport.write_all(&bytes).await?; + Ok(()) + } + + async fn send_do(&mut self, opt: u8) -> Result<(), TelnetError> { + let bytes = [IAC, WILL, opt]; + self.transport.write_all(&bytes).await?; + Ok(()) + } + + async fn send_dont(&mut self, opt: u8) -> Result<(), TelnetError> { + let bytes = [IAC, WONT, opt]; + self.transport.write_all(&bytes).await?; + Ok(()) + } + + // forward write + pub async fn write_all(&mut self, data: &[u8]) -> Result<(), TelnetError> { + self.transport + .write_all(data) + .await + .map_err(TelnetError::Transport) + } + + pub async fn hangup(&mut self) -> Result<(), TelnetError> { + self.transport + .hangup() + .await + .map_err(TelnetError::Transport) + } +} + +impl fmt::Debug for TelnetSession { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TelnetSession").finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::transport::LoopbackTransport; + + #[tokio::test] + async fn telnet_negotiates_echo() { + let (transport, mut client) = LoopbackTransport::new(); + let mut sess = TelnetSession::new(transport); + // client sends WILL ECHO + client.write_bytes(&[IAC, WILL, TELOPT_ECHO]).unwrap(); + + // Process the negotiation in the background; it blocks awaiting more input. + let reader = tokio::spawn(async move { sess.read_byte().await }); + + // Allow the session to answer with DO ECHO, then verify the reply. + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + let out = client.read_output_bytes(); + assert_eq!(out, vec![IAC, DO, TELOPT_ECHO]); + + // Closing the client signals EOF, unblocking the reader with no data. + drop(client); + assert!(reader.await.unwrap().unwrap().is_none()); + } + + #[tokio::test] + async fn echo_and_passthrough() { + let (transport, mut client) = LoopbackTransport::new(); + let mut sess = TelnetSession::new(transport); + // simulate client enabling echo + client.write_bytes(&[IAC, WILL, TELOPT_ECHO]).unwrap(); + client.read_output_bytes(); // consume response + // client sends normal data 'A','B' + client.write_bytes(b"AB").unwrap(); + // server reads + assert_eq!(sess.read_byte().await.unwrap(), Some(b'A')); + assert_eq!(sess.read_byte().await.unwrap(), Some(b'B')); + // After the caller closes, the reader observes EOF rather than echoing. + drop(client); + assert!(sess.read_byte().await.unwrap().is_none()); + } + + #[test] + fn parser_emits_will_negotiation_with_accept_reply() { + let mut parser = TelnetParser::new(); + let mut reply = Vec::new(); + assert!(parser.feed(IAC, &mut reply).is_none()); + assert!(parser.feed(WILL, &mut reply).is_none()); + let event = parser.feed(TELOPT_ECHO, &mut reply); + assert_eq!( + event, + Some(TelnetEvent::Negotiation { + command: TelnetCommand::Will, + option: TELOPT_ECHO, + accepted: true, + }) + ); + assert_eq!(reply, vec![IAC, DO, TELOPT_ECHO]); + } + + #[test] + fn parser_emits_terminal_type_event() { + let mut parser = TelnetParser::new(); + let mut reply = Vec::new(); + let mut event = None; + for &b in &[ + IAC, + SB, + TELOPT_TERMINAL_TYPE, + TELOPT_TTYPE_IS, + b'S', + b'y', + b'n', + b'c', + IAC, + SE, + ] { + if let Some(ev) = parser.feed(b, &mut reply) { + event = Some(ev); + } + } + assert_eq!(event, Some(TelnetEvent::TerminalType(b"Sync".to_vec()))); + } +} diff --git a/crates/oxidebbs-telnet/src/transport.rs b/crates/oxidebbs-telnet/src/transport.rs index fbeec39..f69d8d1 100644 --- a/crates/oxidebbs-telnet/src/transport.rs +++ b/crates/oxidebbs-telnet/src/transport.rs @@ -117,7 +117,10 @@ impl LoopbackTransport { impl Transport for LoopbackTransport { async fn read_byte(&mut self) -> Result, TransportError> { - Ok(self.rx.recv().await) + match self.rx.recv().await { + Some(b) => Ok(Some(b)), + None => Ok(None), + } } async fn write_all(&mut self, bytes: &[u8]) -> Result<(), TransportError> { diff --git a/crates/oxidebbs-transfer/src/zmodem.rs b/crates/oxidebbs-transfer/src/zmodem.rs index 64afcaa..7e44d52 100644 --- a/crates/oxidebbs-transfer/src/zmodem.rs +++ b/crates/oxidebbs-transfer/src/zmodem.rs @@ -1557,3 +1557,52 @@ mod tests { assert_eq!(file.payload, b"retry"); } } + +#[cfg(test)] +mod broken_nested_tests { + use super::*; + + #[test] + fn zmodem_header_from_flags_round_trip() { + let header = ZmodemHeader::new(ZmodemFrameKind::Zfile, 0x1234_5678); + let flags = header.flags; + let constructed = + ZmodemHeader::from_flags(header.kind, flags[3], flags[2], flags[1], flags[0]); + assert_eq!(constructed.kind, header.kind); + assert_eq!(constructed.flags, flags); + assert_eq!(constructed.position(), header.position()); + } + + #[test] + fn frame_end_to_byte_matches_constants() { + assert_eq!(FrameEnd::Zcrce.to_byte(), ZCRCE); + assert_eq!(FrameEnd::Zcrcg.to_byte(), ZCRCG); + assert_eq!(FrameEnd::Zcrcq.to_byte(), ZCRCQ); + assert_eq!(FrameEnd::Zcrcw.to_byte(), ZCRCW); + } + + #[test] + fn zdle_unescape_incomplete_sequence_returns_error() { + let data = vec![ZDLE]; + assert!(zdle_unescape(&data).is_err()); + } + + #[test] + fn zfile_metadata_parse_missing_optional_fields() { + let data = b"file.txt\0".to_vec(); + let parsed = ZfileMetadata::parse(&data).expect("parse missing fields"); + assert_eq!(parsed.pathname, "file.txt"); + assert!(parsed.size.is_none()); + assert!(parsed.mtime.is_none()); + assert!(parsed.mode.is_none()); + } + + #[test] + fn zmodem_header_position_zero_round_trip() { + let header = ZmodemHeader::new(ZmodemFrameKind::Zdata, 0); + assert_eq!(header.position(), 0); + let encoded = encode_binary_header(header); + let decoded = decode_binary_header(&encoded).expect("decode"); + assert_eq!(decoded.position(), 0); + } +} From 4d886dacb3742b1aca1ef0c2d8cca833f5546de2 Mon Sep 17 00:00:00 2001 From: Steven Hildreth Date: Tue, 14 Jul 2026 07:03:36 -0500 Subject: [PATCH 6/9] feat: Implement full PETSCII support for C64 terminal profile - Updated terminal profile to use PETSCII charset instead of ASCII fallback. - Added PETSCII encode/decode functionality with comprehensive tests. - Introduced `TerminalCharset::Petscii` for configuration. - Enhanced documentation and changelog to reflect PETSCII integration. - Established a policy for character translation and terminal profile persistence. --- CHANGELOG.md | 2 +- README.md | 7 +- config/oxidebbs.example.toml | 2 +- crates/oxidebbs-server/src/config.rs | 6 +- crates/oxidebbs-server/src/serve.rs | 921 +++++++++++++----- crates/oxidebbs-server/src/setup.rs | 2 +- crates/oxidebbs-term/src/lib.rs | 184 +++- design/TASKS.md | 36 +- ...lation-and-terminal-profile-persistence.md | 80 ++ docs/about/changelog.md | 19 + 10 files changed, 995 insertions(+), 264 deletions(-) create mode 100644 design/adr/0034-petscii-translation-and-terminal-profile-persistence.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0409c3d..549f041 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,4 +7,4 @@ The canonical OxideBBS changelog is maintained in the documentation site source: Keep this root file as a pointer so package managers, repository browsers, and contributors can find the current changelog without duplicating release notes. -- Added `constants.rs` to centralise default configuration values such as the default door time limit and Binkp port. + diff --git a/README.md b/README.md index 6a6bfca..e2ccebb 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,10 @@ terminal profile; this is not a C64-native server port. - 📡 **Telnet And Serial Caller Runtime** - Multi-node telnet serving plus disabled-by-default physical serial/modem devices, with session lifecycle tracking, idle timeout handling, graceful shutdown, and live node state. -- 🎨 **ANSI/CP437-First UI** - Raw ANSI assets, CP437 conversion, C64-friendly - 40-column plain/PETSCII-fallback caller profile, 80-column ANSI profile, - paging, caller-safe prompts, and CRLF-normalized telnet output. +- 🎨 **ANSI/CP437-First UI** - Raw ANSI assets, CP437 conversion, a C64-friendly + 40-column profile with full PETSCII encode/decode for Commodore callers, an + 80-column ANSI profile, paging, caller-safe prompts, and CRLF-normalized + telnet output. - 🧭 **Configurable Menus** - Login, main, info, message, door, logoff, and nested submenu routing with hotkey-driven caller commands. - 👤 **Accounts And Auth** - New-user creation, Argon2id password hashing, diff --git a/config/oxidebbs.example.toml b/config/oxidebbs.example.toml index 31398a3..133dba7 100644 --- a/config/oxidebbs.example.toml +++ b/config/oxidebbs.example.toml @@ -85,7 +85,7 @@ width = 40 height = 25 supports_ansi = false supports_color = false -charset = "petscii_ascii_fallback" +charset = "petscii" line_endings = "crlf" backspace_mode = "backspace_or_delete" output_pacing_bytes_per_second = 1200 diff --git a/crates/oxidebbs-server/src/config.rs b/crates/oxidebbs-server/src/config.rs index a656e6f..e55817f 100644 --- a/crates/oxidebbs-server/src/config.rs +++ b/crates/oxidebbs-server/src/config.rs @@ -1250,7 +1250,7 @@ fn default_terminal_profiles() -> HashMap { height: 25, supports_ansi: false, supports_color: false, - charset: "petscii_ascii_fallback".to_string(), + charset: "petscii".to_string(), line_endings: "crlf".to_string(), backspace_mode: "backspace_or_delete".to_string(), output_pacing_bytes_per_second: Some(1_200), @@ -1519,11 +1519,12 @@ fn validate_terminal_charset(charset: &str) -> Result<&'static str, String> { match charset.trim().to_ascii_lowercase().as_str() { "cp437" => Ok("cp437"), "ascii" => Ok("ascii"), + "petscii" => Ok("petscii"), "petscii_ascii_fallback" | "petscii-ascii-fallback" | "petscii40" => { Ok("petscii_ascii_fallback") } other => Err(format!( - "terminal charset must be one of cp437, ascii, or petscii_ascii_fallback, got {other:?}" + "terminal charset must be one of cp437, ascii, petscii, or petscii_ascii_fallback, got {other:?}" )), } } @@ -1531,6 +1532,7 @@ fn validate_terminal_charset(charset: &str) -> Result<&'static str, String> { fn terminal_charset(charset: &str) -> Result { Ok(match validate_terminal_charset(charset)? { "cp437" => TerminalCharset::Cp437, + "petscii" => TerminalCharset::Petscii, "petscii_ascii_fallback" => TerminalCharset::PetsciiAsciiFallback, _ => TerminalCharset::Ascii, }) diff --git a/crates/oxidebbs-server/src/serve.rs b/crates/oxidebbs-server/src/serve.rs index 06146bf..2138f76 100644 --- a/crates/oxidebbs-server/src/serve.rs +++ b/crates/oxidebbs-server/src/serve.rs @@ -43,8 +43,8 @@ use oxidebbs_telnet::{ TransportError, }; use oxidebbs_term::{ - LoadedScreen, ScreenAsset as TermScreenAsset, TerminalCapabilities, TerminalProfile, - encode_cp437, + LoadedScreen, ScreenAsset as TermScreenAsset, TerminalCapabilities, TerminalCharset, + TerminalProfile, char_to_petscii_byte, encode_cp437, render_petscii_lossy, }; use oxidebbs_transfer::adapter::TransportAdapter; use oxidebbs_transfer::{ @@ -457,7 +457,7 @@ where } async fn reject_connection(mut stream: TcpStream) -> ServeResult<()> { - let bytes = encode_text(REJECTION_MESSAGE); + let bytes = encode_text(REJECTION_MESSAGE, TerminalCharset::Cp437); stream.write_all(&bytes).await?; stream.shutdown().await?; Ok(()) @@ -803,6 +803,7 @@ async fn handle_caller_transport( &mut transport, runtime.take_node_commands(node_number_u16), &mut disconnect_reason, + capabilities.charset, ) .await? { @@ -820,8 +821,13 @@ async fn handle_caller_transport( let event = match wait { CallerWait::Runtime(commands) => { - if process_runtime_commands(&mut transport, commands, &mut disconnect_reason) - .await? + if process_runtime_commands( + &mut transport, + commands, + &mut disconnect_reason, + capabilities.charset, + ) + .await? { break; } @@ -839,7 +845,12 @@ async fn handle_caller_transport( } Ok(CallerInput::IdleTimeout) => { disconnect_reason = "idle_timeout".to_string(); - send_text(&mut transport, "Idle timeout. Goodbye.\r\n").await?; + send_text( + &mut transport, + "Idle timeout. Goodbye.\r\n", + capabilities.charset, + ) + .await?; break; } Err(error) => { @@ -879,7 +890,13 @@ async fn handle_caller_transport( &screen_context, ) .await?; - send_menu_prompt(&mut transport, ¤t_menu, &screen_context).await?; + send_menu_prompt( + &mut transport, + ¤t_menu, + &screen_context, + capabilities.charset, + ) + .await?; continue; } if key == "R" && current_menu.route_entry("R").is_none() { @@ -891,7 +908,13 @@ async fn handle_caller_transport( &screen_context, ) .await?; - send_menu_prompt(&mut transport, ¤t_menu, &screen_context).await?; + send_menu_prompt( + &mut transport, + ¤t_menu, + &screen_context, + capabilities.charset, + ) + .await?; continue; } @@ -901,8 +924,15 @@ async fn handle_caller_transport( && let Some(entry) = current_menu.route_entry(&key) && entry.min_security_level > 0 { - send_text(&mut transport, ACCESS_DENIED_MESSAGE).await?; - send_menu_prompt(&mut transport, ¤t_menu, &screen_context).await?; + send_text(&mut transport, ACCESS_DENIED_MESSAGE, capabilities.charset) + .await?; + send_menu_prompt( + &mut transport, + ¤t_menu, + &screen_context, + capabilities.charset, + ) + .await?; continue; } match route { @@ -919,8 +949,13 @@ async fn handle_caller_transport( idle_timeout, disconnect_reason: &mut disconnect_reason, }; - match run_login_flow(&mut transport, &mut input, &mut auth_state) - .await? + match run_login_flow( + &mut transport, + &mut input, + &mut auth_state, + capabilities.charset, + ) + .await? { AuthFlowResult::Success => { if let Some(user) = authenticated_user.as_ref() { @@ -956,6 +991,7 @@ async fn handle_caller_transport( &mut transport, ¤t_menu, &screen_context, + capabilities.charset, ) .await?; } @@ -975,8 +1011,13 @@ async fn handle_caller_transport( idle_timeout, disconnect_reason: &mut disconnect_reason, }; - match run_new_user_flow(&mut transport, &mut input, &mut auth_state) - .await? + match run_new_user_flow( + &mut transport, + &mut input, + &mut auth_state, + capabilities.charset, + ) + .await? { AuthFlowResult::Success => { if let Some(user) = authenticated_user.as_ref() { @@ -1012,6 +1053,7 @@ async fn handle_caller_transport( &mut transport, ¤t_menu, &screen_context, + capabilities.charset, ) .await?; } @@ -1034,23 +1076,43 @@ async fn handle_caller_transport( debug!(node = %node_number, submenu = %menu_id, "caller selected submenu"); if let Some(submenu) = resolve_submenu(&menus, &menu_id) { current_menu = Arc::clone(&submenu); - send_menu_prompt(&mut transport, ¤t_menu, &screen_context) - .await?; + send_menu_prompt( + &mut transport, + ¤t_menu, + &screen_context, + capabilities.charset, + ) + .await?; } else { send_text( &mut transport, "Configured submenu menu is missing.\r\n", + capabilities.charset, + ) + .await?; + send_menu_prompt( + &mut transport, + ¤t_menu, + &screen_context, + capabilities.charset, ) .await?; - send_menu_prompt(&mut transport, ¤t_menu, &screen_context) - .await?; } } _ => { - send_text(&mut transport, "Select Login, New User, or Goodbye.\r\n") - .await?; - send_menu_prompt(&mut transport, ¤t_menu, &screen_context) - .await?; + send_text( + &mut transport, + "Select Login, New User, or Goodbye.\r\n", + capabilities.charset, + ) + .await?; + send_menu_prompt( + &mut transport, + ¤t_menu, + &screen_context, + capabilities.charset, + ) + .await?; } } } else { @@ -1066,8 +1128,15 @@ async fn handle_caller_transport( node = %node_number, "caller denied by menu item min_security_level" ); - send_text(&mut transport, ACCESS_DENIED_MESSAGE).await?; - send_menu_prompt(&mut transport, ¤t_menu, &screen_context).await?; + send_text(&mut transport, ACCESS_DENIED_MESSAGE, capabilities.charset) + .await?; + send_menu_prompt( + &mut transport, + ¤t_menu, + &screen_context, + capabilities.charset, + ) + .await?; continue; } match current_menu.route(&key) { @@ -1086,6 +1155,7 @@ async fn handle_caller_transport( &mut transport, &mut input, &mut door_state, + capabilities.charset, ) .await? { @@ -1095,6 +1165,7 @@ async fn handle_caller_transport( &mut transport, ¤t_menu, &screen_context, + capabilities.charset, ) .await?; } @@ -1116,6 +1187,7 @@ async fn handle_caller_transport( &mut transport, &mut input, &mut message_state, + capabilities.charset, ) .await? { @@ -1125,6 +1197,7 @@ async fn handle_caller_transport( &mut transport, ¤t_menu, &screen_context, + capabilities.charset, ) .await?; } @@ -1145,6 +1218,7 @@ async fn handle_caller_transport( idle_timeout, &mut disconnect_reason, node_number_u16, + capabilities.charset, ) .await? { @@ -1154,6 +1228,7 @@ async fn handle_caller_transport( &mut transport, ¤t_menu, &screen_context, + capabilities.charset, ) .await?; } @@ -1162,10 +1237,19 @@ async fn handle_caller_transport( } Some(MenuAction::NewUser) => { debug!(node = %node_number, "authenticated caller selected new-user action"); - send_text(&mut transport, "Already signed in. Return to menu.\r\n") - .await?; - send_menu_prompt(&mut transport, ¤t_menu, &screen_context) - .await?; + send_text( + &mut transport, + "Already signed in. Return to menu.\r\n", + capabilities.charset, + ) + .await?; + send_menu_prompt( + &mut transport, + ¤t_menu, + &screen_context, + capabilities.charset, + ) + .await?; } Some(MenuAction::Logoff) => { debug!(node = %node_number, "caller selected main-menu logoff"); @@ -1189,31 +1273,56 @@ async fn handle_caller_transport( &screen_context, ) .await?; - send_menu_prompt(&mut transport, ¤t_menu, &screen_context) - .await?; + send_menu_prompt( + &mut transport, + ¤t_menu, + &screen_context, + capabilities.charset, + ) + .await?; } Some(MenuAction::Submenu { menu_id }) => { debug!(node = %node_number, submenu = %menu_id, "caller selected submenu"); if let Some(submenu) = resolve_submenu(&menus, &menu_id) { current_menu = Arc::clone(&submenu); - send_menu_prompt(&mut transport, ¤t_menu, &screen_context) - .await?; + send_menu_prompt( + &mut transport, + ¤t_menu, + &screen_context, + capabilities.charset, + ) + .await?; } else { send_text( &mut transport, "Configured submenu menu is missing.\r\n", + capabilities.charset, + ) + .await?; + send_menu_prompt( + &mut transport, + ¤t_menu, + &screen_context, + capabilities.charset, ) .await?; - send_menu_prompt(&mut transport, ¤t_menu, &screen_context) - .await?; } } Some(MenuAction::Login) => { debug!(node = %node_number, "authenticated caller selected login action"); - send_text(&mut transport, "Already signed in. Return to menu.\r\n") - .await?; - send_menu_prompt(&mut transport, ¤t_menu, &screen_context) - .await?; + send_text( + &mut transport, + "Already signed in. Return to menu.\r\n", + capabilities.charset, + ) + .await?; + send_menu_prompt( + &mut transport, + ¤t_menu, + &screen_context, + capabilities.charset, + ) + .await?; } Some(MenuAction::Noop) => { debug!(node = %node_number, "caller selected noop action"); @@ -1225,9 +1334,15 @@ async fn handle_caller_transport( key = %key, "caller selected unknown menu key" ); - send_text(&mut transport, "Unknown option.\r\n").await?; - send_menu_prompt(&mut transport, ¤t_menu, &screen_context) + send_text(&mut transport, "Unknown option.\r\n", capabilities.charset) .await?; + send_menu_prompt( + &mut transport, + ¤t_menu, + &screen_context, + capabilities.charset, + ) + .await?; } } } @@ -1514,6 +1629,7 @@ async fn run_login_flow( transport: &mut T, input: &mut InputSession, state: &mut AuthFlowState<'_>, + charset: TerminalCharset, ) -> ServeResult { let db = state.db; let node_number = state.node_number; @@ -1522,41 +1638,59 @@ async fn run_login_flow( let disconnect_reason = &mut *state.disconnect_reason; let authenticated_user = &mut *state.authenticated_user; - send_text(transport, "\r\n-- Login --\r\n").await?; + send_text(transport, "\r\n-- Login --\r\n", charset).await?; - let alias = - match prompt_for_line(transport, input, idle_timeout, false, false, "Alias: ").await? { - PromptLineResult::Value(value) => value, - PromptLineResult::Disconnected => { - *disconnect_reason = "caller_dropped_during_login".to_string(); - return Ok(AuthFlowResult::Exit); - } - PromptLineResult::IdleTimeout => { - *disconnect_reason = "idle_timeout".to_string(); - send_text(transport, "Idle timeout. Goodbye.\r\n").await?; - return Ok(AuthFlowResult::Exit); - } - PromptLineResult::Rejected => { - unreachable!("prompt_for_line handles rejected input internally"); - } - }; + let alias = match prompt_for_line( + transport, + input, + idle_timeout, + false, + false, + "Alias: ", + charset, + ) + .await? + { + PromptLineResult::Value(value) => value, + PromptLineResult::Disconnected => { + *disconnect_reason = "caller_dropped_during_login".to_string(); + return Ok(AuthFlowResult::Exit); + } + PromptLineResult::IdleTimeout => { + *disconnect_reason = "idle_timeout".to_string(); + send_text(transport, "Idle timeout. Goodbye.\r\n", charset).await?; + return Ok(AuthFlowResult::Exit); + } + PromptLineResult::Rejected => { + unreachable!("prompt_for_line handles rejected input internally"); + } + }; - let password = - match prompt_for_line(transport, input, idle_timeout, false, true, "Password: ").await? { - PromptLineResult::Value(value) => value, - PromptLineResult::Disconnected => { - *disconnect_reason = "caller_dropped_during_login".to_string(); - return Ok(AuthFlowResult::Exit); - } - PromptLineResult::IdleTimeout => { - *disconnect_reason = "idle_timeout".to_string(); - send_text(transport, "Idle timeout. Goodbye.\r\n").await?; - return Ok(AuthFlowResult::Exit); - } - PromptLineResult::Rejected => { - unreachable!("prompt_for_line handles rejected input internally"); - } - }; + let password = match prompt_for_line( + transport, + input, + idle_timeout, + false, + true, + "Password: ", + charset, + ) + .await? + { + PromptLineResult::Value(value) => value, + PromptLineResult::Disconnected => { + *disconnect_reason = "caller_dropped_during_login".to_string(); + return Ok(AuthFlowResult::Exit); + } + PromptLineResult::IdleTimeout => { + *disconnect_reason = "idle_timeout".to_string(); + send_text(transport, "Idle timeout. Goodbye.\r\n", charset).await?; + return Ok(AuthFlowResult::Exit); + } + PromptLineResult::Rejected => { + unreachable!("prompt_for_line handles rejected input internally"); + } + }; let login_at = current_timestamp(db)?; let alias_scope_key = normalize_alias(&alias); @@ -1569,7 +1703,7 @@ async fn run_login_flow( alias_scope = %alias_scope_key, "login rejected by rate limiter" ); - send_text(transport, LOGIN_LOCKOUT_MESSAGE).await?; + send_text(transport, LOGIN_LOCKOUT_MESSAGE, charset).await?; return Ok(AuthFlowResult::Retry); } @@ -1597,7 +1731,7 @@ async fn run_login_flow( alias_scope = %alias_scope_key, "login rejected for unknown alias" ); - send_text(transport, INVALID_LOGIN_MESSAGE).await?; + send_text(transport, INVALID_LOGIN_MESSAGE, charset).await?; return Ok(AuthFlowResult::Retry); }; @@ -1644,7 +1778,7 @@ async fn run_login_flow( verification = ?verification, "login rejected for user" ); - send_text(transport, INVALID_LOGIN_MESSAGE).await?; + send_text(transport, INVALID_LOGIN_MESSAGE, charset).await?; return Ok(AuthFlowResult::Retry); } @@ -1704,7 +1838,7 @@ async fn run_login_flow( ); *authenticated_user = Some(user); - send_text(transport, "Login successful. Welcome back.\r\n").await?; + send_text(transport, "Login successful. Welcome back.\r\n", charset).await?; Ok(AuthFlowResult::Success) } @@ -1712,6 +1846,7 @@ async fn run_new_user_flow( transport: &mut T, input: &mut InputSession, state: &mut AuthFlowState<'_>, + charset: TerminalCharset, ) -> ServeResult { let db = state.db; let node_number = state.node_number; @@ -1720,7 +1855,7 @@ async fn run_new_user_flow( let disconnect_reason = &mut *state.disconnect_reason; let authenticated_user = &mut *state.authenticated_user; - send_text(transport, "\r\n-- Registration --\r\n").await?; + send_text(transport, "\r\n-- Registration --\r\n", charset).await?; let alias = match prompt_for_line( transport, @@ -1729,6 +1864,7 @@ async fn run_new_user_flow( false, false, "Choose an alias: ", + charset, ) .await? { @@ -1739,7 +1875,7 @@ async fn run_new_user_flow( } PromptLineResult::IdleTimeout => { *disconnect_reason = "idle_timeout".to_string(); - send_text(transport, "Idle timeout. Goodbye.\r\n").await?; + send_text(transport, "Idle timeout. Goodbye.\r\n", charset).await?; return Ok(AuthFlowResult::Exit); } PromptLineResult::Rejected => { @@ -1747,22 +1883,31 @@ async fn run_new_user_flow( } }; - let real_name = - match prompt_for_line(transport, input, idle_timeout, false, false, "Real name: ").await? { - PromptLineResult::Value(value) => value, - PromptLineResult::Disconnected => { - *disconnect_reason = "caller_dropped_during_login".to_string(); - return Ok(AuthFlowResult::Exit); - } - PromptLineResult::IdleTimeout => { - *disconnect_reason = "idle_timeout".to_string(); - send_text(transport, "Idle timeout. Goodbye.\r\n").await?; - return Ok(AuthFlowResult::Exit); - } - PromptLineResult::Rejected => { - unreachable!("prompt_for_line handles rejected input internally"); - } - }; + let real_name = match prompt_for_line( + transport, + input, + idle_timeout, + false, + false, + "Real name: ", + charset, + ) + .await? + { + PromptLineResult::Value(value) => value, + PromptLineResult::Disconnected => { + *disconnect_reason = "caller_dropped_during_login".to_string(); + return Ok(AuthFlowResult::Exit); + } + PromptLineResult::IdleTimeout => { + *disconnect_reason = "idle_timeout".to_string(); + send_text(transport, "Idle timeout. Goodbye.\r\n", charset).await?; + return Ok(AuthFlowResult::Exit); + } + PromptLineResult::Rejected => { + unreachable!("prompt_for_line handles rejected input internally"); + } + }; let email = match prompt_for_line( transport, @@ -1771,6 +1916,7 @@ async fn run_new_user_flow( true, false, "Email (optional): ", + charset, ) .await? { @@ -1788,7 +1934,7 @@ async fn run_new_user_flow( } PromptLineResult::IdleTimeout => { *disconnect_reason = "idle_timeout".to_string(); - send_text(transport, "Idle timeout. Goodbye.\r\n").await?; + send_text(transport, "Idle timeout. Goodbye.\r\n", charset).await?; return Ok(AuthFlowResult::Exit); } PromptLineResult::Rejected => { @@ -1803,6 +1949,7 @@ async fn run_new_user_flow( false, true, "Choose password: ", + charset, ) .await? { @@ -1813,7 +1960,7 @@ async fn run_new_user_flow( } PromptLineResult::IdleTimeout => { *disconnect_reason = "idle_timeout".to_string(); - send_text(transport, "Idle timeout. Goodbye.\r\n").await?; + send_text(transport, "Idle timeout. Goodbye.\r\n", charset).await?; return Ok(AuthFlowResult::Exit); } PromptLineResult::Rejected => { @@ -1828,6 +1975,7 @@ async fn run_new_user_flow( false, true, "Confirm password: ", + charset, ) .await? { @@ -1838,7 +1986,7 @@ async fn run_new_user_flow( } PromptLineResult::IdleTimeout => { *disconnect_reason = "idle_timeout".to_string(); - send_text(transport, "Idle timeout. Goodbye.\r\n").await?; + send_text(transport, "Idle timeout. Goodbye.\r\n", charset).await?; return Ok(AuthFlowResult::Exit); } PromptLineResult::Rejected => { @@ -1847,7 +1995,7 @@ async fn run_new_user_flow( }; if password != password_confirmation { - send_text(transport, "Passwords did not match.\r\n").await?; + send_text(transport, "Passwords did not match.\r\n", charset).await?; return Ok(AuthFlowResult::Retry); } @@ -1864,7 +2012,12 @@ async fn run_new_user_flow( }) { Ok(user) => user, Err(error) => { - send_text(transport, &format!("Unable to create account: {error}\r\n")).await?; + send_text( + transport, + &format!("Unable to create account: {error}\r\n"), + charset, + ) + .await?; return Ok(AuthFlowResult::Retry); } }; @@ -1892,7 +2045,7 @@ async fn run_new_user_flow( alias = %user.alias, "new-user alias rejected as duplicate" ); - send_text(transport, "That alias is already in use.\r\n").await?; + send_text(transport, "That alias is already in use.\r\n", charset).await?; return Ok(AuthFlowResult::Retry); } UserInsertError::Db(error) => { @@ -1972,7 +2125,7 @@ async fn run_new_user_flow( "new user created and signed in" ); - send_text(transport, "Account created. Welcome.\r\n").await?; + send_text(transport, "Account created. Welcome.\r\n", charset).await?; Ok(AuthFlowResult::Success) } @@ -1981,21 +2134,27 @@ async fn run_doors_flow( transport: &mut impl Transport, input: &mut InputSession, state: &mut DoorFlowState<'_>, + charset: TerminalCharset, ) -> ServeResult { let Some(user) = authenticated_user else { - send_text(transport, "You must be signed in to use doors.\r\n").await?; + send_text( + transport, + "You must be signed in to use doors.\r\n", + charset, + ) + .await?; return Ok(MenuFlowResult::Continue); }; let service = DoorService::new(state.db, state.config); let doors = service.list_enabled_doors()?; if doors.is_empty() { - send_text(transport, "No doors are available.\r\n").await?; + send_text(transport, "No doors are available.\r\n", charset).await?; return Ok(MenuFlowResult::Continue); } loop { - send_text(transport, &render_door_menu(&doors)).await?; + send_text(transport, &render_door_menu(&doors), charset).await?; let selected = match prompt_for_line( transport, input, @@ -2003,6 +2162,7 @@ async fn run_doors_flow( true, false, "Door key or number (blank to return): ", + charset, ) .await? { @@ -2013,7 +2173,7 @@ async fn run_doors_flow( } PromptLineResult::IdleTimeout => { *state.disconnect_reason = "idle_timeout".to_string(); - send_text(transport, "Idle timeout. Goodbye.\r\n").await?; + send_text(transport, "Idle timeout. Goodbye.\r\n", charset).await?; return Ok(MenuFlowResult::Exit); } PromptLineResult::Rejected => { @@ -2025,7 +2185,7 @@ async fn run_doors_flow( DoorSelection::Return => return Ok(MenuFlowResult::Continue), DoorSelection::Door(door) => door, DoorSelection::Invalid => { - send_text(transport, "Unknown door.\r\n").await?; + send_text(transport, "Unknown door.\r\n", charset).await?; continue; } }; @@ -2046,7 +2206,7 @@ async fn run_doors_flow( door_key = %door.key, "caller denied by door min_security_level" ); - send_text(transport, ACCESS_DENIED_MESSAGE).await?; + send_text(transport, ACCESS_DENIED_MESSAGE, charset).await?; continue; } @@ -2067,12 +2227,18 @@ async fn run_doors_flow( send_text( transport, "This door is not available right now. Contact the sysop.\r\n", + charset, ) .await?; continue; } - send_text(transport, &format!("\r\nLaunching {}...\r\n", door.name)).await?; + send_text( + transport, + &format!("\r\nLaunching {}...\r\n", door.name), + charset, + ) + .await?; let summary = service .execute_interactive(transport, state.runtime, user, state.node_number, door) .await?; @@ -2102,7 +2268,7 @@ async fn run_doors_flow( } state.runtime.mark_node_main_menu(state.node_number); - send_text(transport, &door_summary_text(&summary)).await?; + send_text(transport, &door_summary_text(&summary), charset).await?; return Ok(MenuFlowResult::Continue); } } @@ -2156,6 +2322,7 @@ async fn run_messages_flow( transport: &mut impl Transport, input: &mut InputSession, state: &mut MessageFlowState<'_>, + charset: TerminalCharset, ) -> ServeResult { let db = state.db; let idle_timeout = state.idle_timeout; @@ -2164,26 +2331,32 @@ async fn run_messages_flow( let disconnect_reason = &mut *state.disconnect_reason; let Some(user) = authenticated_user else { - send_text(transport, "You must be signed in to use messages.\r\n").await?; + send_text( + transport, + "You must be signed in to use messages.\r\n", + charset, + ) + .await?; return Ok(MenuFlowResult::Continue); }; - ensure_default_message_area(db, transport).await?; + ensure_default_message_area(db, transport, charset).await?; let area_records = list_message_areas(db.db())? .into_iter() .filter(|area| area.enabled) .collect::>(); if area_records.is_empty() { - send_text(transport, "No message areas are configured.\r\n").await?; + send_text(transport, "No message areas are configured.\r\n", charset).await?; return Ok(MenuFlowResult::Continue); } loop { - send_text(transport, "\r\nMessage areas:\r\n").await?; + send_text(transport, "\r\nMessage areas:\r\n", charset).await?; for area in &area_records { send_text( transport, &format!("{} - {}\r\n", area.key, area.description), + charset, ) .await?; } @@ -2195,6 +2368,7 @@ async fn run_messages_flow( true, false, "Area key (blank to return): ", + charset, ) .await? { @@ -2205,7 +2379,7 @@ async fn run_messages_flow( } PromptLineResult::IdleTimeout => { *disconnect_reason = "idle_timeout".to_string(); - send_text(transport, "Idle timeout. Goodbye.\r\n").await?; + send_text(transport, "Idle timeout. Goodbye.\r\n", charset).await?; return Ok(MenuFlowResult::Exit); } PromptLineResult::Rejected => { @@ -2223,7 +2397,7 @@ async fn run_messages_flow( { Some(area) => area, None => { - send_text(transport, "Unknown area.\r\n").await?; + send_text(transport, "Unknown area.\r\n", charset).await?; continue; } }; @@ -2240,7 +2414,7 @@ async fn run_messages_flow( loop { runtime.mark_node_reading_messages(node_number); let visible = visible_messages_for_user(db, &area, user.security_level)?; - display_message_list(transport, db, &area, &visible).await?; + display_message_list(transport, db, &area, &visible, charset).await?; let action = match prompt_for_line( transport, @@ -2249,6 +2423,7 @@ async fn run_messages_flow( true, false, "Read (R), Post (P), Reply (Y), Back (blank): ", + charset, ) .await? { @@ -2259,7 +2434,7 @@ async fn run_messages_flow( } PromptLineResult::IdleTimeout => { *disconnect_reason = "idle_timeout".to_string(); - send_text(transport, "Idle timeout. Goodbye.\r\n").await?; + send_text(transport, "Idle timeout. Goodbye.\r\n", charset).await?; return Ok(MenuFlowResult::Exit); } PromptLineResult::Rejected => { @@ -2282,6 +2457,7 @@ async fn run_messages_flow( disconnect_reason, visible.len(), "Message number to read: ", + charset, ) .await? { @@ -2298,7 +2474,7 @@ async fn run_messages_flow( action = "read", "caller selected message" ); - display_message(transport, db, &visible[index]).await?; + display_message(transport, db, &visible[index], charset).await?; } Some('P') => { runtime.mark_node_posting_message(node_number); @@ -2309,6 +2485,7 @@ async fn run_messages_flow( false, false, "Message subject: ", + charset, ) .await? { @@ -2319,7 +2496,7 @@ async fn run_messages_flow( } PromptLineResult::IdleTimeout => { *disconnect_reason = "idle_timeout".to_string(); - send_text(transport, "Idle timeout. Goodbye.\r\n").await?; + send_text(transport, "Idle timeout. Goodbye.\r\n", charset).await?; return Ok(MenuFlowResult::Exit); } PromptLineResult::Rejected => { @@ -2327,11 +2504,17 @@ async fn run_messages_flow( } }; if validate_caller_cp437_text(&subject).is_err() { - send_text(transport, CP437_INPUT_REJECT_LINE).await?; + send_text(transport, CP437_INPUT_REJECT_LINE, charset).await?; continue; } - let body = match prompt_for_message_body(transport, input, idle_timeout).await? + let body = match prompt_for_message_body( + transport, + input, + idle_timeout, + charset, + ) + .await? { PromptLineResult::Value(value) => value, PromptLineResult::Disconnected => { @@ -2340,7 +2523,7 @@ async fn run_messages_flow( } PromptLineResult::IdleTimeout => { *disconnect_reason = "idle_timeout".to_string(); - send_text(transport, "Idle timeout. Goodbye.\r\n").await?; + send_text(transport, "Idle timeout. Goodbye.\r\n", charset).await?; return Ok(MenuFlowResult::Exit); } PromptLineResult::Rejected => { @@ -2350,7 +2533,7 @@ async fn run_messages_flow( } }; if validate_caller_cp437_text(&body).is_err() { - send_text(transport, CP437_INPUT_REJECT_LINE).await?; + send_text(transport, CP437_INPUT_REJECT_LINE, charset).await?; continue; } @@ -2366,8 +2549,12 @@ async fn run_messages_flow( let message = match post_message(&area, draft) { Ok(message) => message, Err(error) => { - send_text(transport, &format!("Cannot post message: {error}\r\n")) - .await?; + send_text( + transport, + &format!("Cannot post message: {error}\r\n"), + charset, + ) + .await?; continue; } }; @@ -2394,12 +2581,12 @@ async fn run_messages_flow( action = "post", "caller posted message" ); - send_text(transport, "Message posted.\r\n").await?; + send_text(transport, "Message posted.\r\n", charset).await?; runtime.mark_node_reading_messages(node_number); } Some('Y') => { if visible.is_empty() { - send_text(transport, "No messages to reply to.\r\n").await?; + send_text(transport, "No messages to reply to.\r\n", charset).await?; continue; } @@ -2411,6 +2598,7 @@ async fn run_messages_flow( disconnect_reason, visible.len(), "Message number to reply to: ", + charset, ) .await? { @@ -2419,7 +2607,13 @@ async fn run_messages_flow( MessageIndexPromptResult::Exit => return Ok(MenuFlowResult::Exit), }; - let body = match prompt_for_message_body(transport, input, idle_timeout).await? + let body = match prompt_for_message_body( + transport, + input, + idle_timeout, + charset, + ) + .await? { PromptLineResult::Value(value) => value, PromptLineResult::Disconnected => { @@ -2428,7 +2622,7 @@ async fn run_messages_flow( } PromptLineResult::IdleTimeout => { *disconnect_reason = "idle_timeout".to_string(); - send_text(transport, "Idle timeout. Goodbye.\r\n").await?; + send_text(transport, "Idle timeout. Goodbye.\r\n", charset).await?; return Ok(MenuFlowResult::Exit); } PromptLineResult::Rejected => { @@ -2438,7 +2632,7 @@ async fn run_messages_flow( } }; if validate_caller_cp437_text(&body).is_err() { - send_text(transport, CP437_INPUT_REJECT_LINE).await?; + send_text(transport, CP437_INPUT_REJECT_LINE, charset).await?; continue; } @@ -2452,7 +2646,8 @@ async fn run_messages_flow( let message = match reply_message(&area, &visible[index], draft) { Ok(message) => message, Err(error) => { - send_text(transport, &format!("Cannot reply: {error}\r\n")).await?; + send_text(transport, &format!("Cannot reply: {error}\r\n"), charset) + .await?; continue; } }; @@ -2480,11 +2675,11 @@ async fn run_messages_flow( action = "reply", "caller posted reply" ); - send_text(transport, "Reply posted.\r\n").await?; + send_text(transport, "Reply posted.\r\n", charset).await?; runtime.mark_node_reading_messages(node_number); } Some(_) => { - send_text(transport, "Unknown command.\r\n").await?; + send_text(transport, "Unknown command.\r\n", charset).await?; } } } @@ -2502,14 +2697,20 @@ async fn run_files_flow( idle_timeout: Duration, disconnect_reason: &mut String, node_number: u16, + charset: TerminalCharset, ) -> ServeResult { let Some(user) = authenticated_user else { - send_text(transport, "You must be signed in to use file areas.\r\n").await?; + send_text( + transport, + "You must be signed in to use file areas.\r\n", + charset, + ) + .await?; return Ok(MenuFlowResult::Continue); }; if !config.file_transfers.enabled { - send_text(transport, "File transfers are disabled.\r\n").await?; + send_text(transport, "File transfers are disabled.\r\n", charset).await?; return Ok(MenuFlowResult::Continue); } @@ -2519,12 +2720,12 @@ async fn run_files_flow( .collect::>(); if file_areas.is_empty() { - send_text(transport, "No file areas are configured.\r\n").await?; + send_text(transport, "No file areas are configured.\r\n", charset).await?; return Ok(MenuFlowResult::Continue); } loop { - send_text(transport, "\r\nFile areas:\r\n").await?; + send_text(transport, "\r\nFile areas:\r\n", charset).await?; for (index, area) in file_areas.iter().enumerate() { let accessible = user.security_level >= area.read_security_level as i32; let marker = if accessible { " " } else { "*" }; @@ -2537,6 +2738,7 @@ async fn run_files_flow( area.key, area.description ), + charset, ) .await?; } @@ -2548,6 +2750,7 @@ async fn run_files_flow( true, false, "Area number (blank to return): ", + charset, ) .await? { @@ -2558,11 +2761,11 @@ async fn run_files_flow( } PromptLineResult::IdleTimeout => { *disconnect_reason = "idle_timeout".to_string(); - send_text(transport, "Idle timeout. Goodbye.\r\n").await?; + send_text(transport, "Idle timeout. Goodbye.\r\n", charset).await?; return Ok(MenuFlowResult::Exit); } PromptLineResult::Rejected => { - send_text(transport, CP437_INPUT_REJECT_LINE).await?; + send_text(transport, CP437_INPUT_REJECT_LINE, charset).await?; continue; } }; @@ -2575,22 +2778,32 @@ async fn run_files_flow( let area_index = match trimmed.parse::() { Ok(n) if n >= 1 && n <= file_areas.len() => n - 1, _ => { - send_text(transport, "Invalid selection.\r\n").await?; + send_text(transport, "Invalid selection.\r\n", charset).await?; continue; } }; let area = &file_areas[area_index]; if user.security_level < area.read_security_level as i32 { - send_text(transport, "Access denied. Security level too low.\r\n").await?; + send_text( + transport, + "Access denied. Security level too low.\r\n", + charset, + ) + .await?; continue; } loop { let files = approved_files_for_area(db, area)?; - send_text(transport, &format!("\r\nFiles in {}:\r\n", area.name)).await?; + send_text( + transport, + &format!("\r\nFiles in {}:\r\n", area.name), + charset, + ) + .await?; if files.is_empty() { - send_text(transport, "No approved files in this area.\r\n").await?; + send_text(transport, "No approved files in this area.\r\n", charset).await?; } else { for (index, file) in files.iter().enumerate() { send_text( @@ -2601,6 +2814,7 @@ async fn run_files_flow( file.display_name, file.size_bytes ), + charset, ) .await?; } @@ -2613,6 +2827,7 @@ async fn run_files_flow( true, false, "Files: D)ownload U)pload R)eturn: ", + charset, ) .await? { @@ -2623,11 +2838,11 @@ async fn run_files_flow( } PromptLineResult::IdleTimeout => { *disconnect_reason = "idle_timeout".to_string(); - send_text(transport, "Idle timeout. Goodbye.\r\n").await?; + send_text(transport, "Idle timeout. Goodbye.\r\n", charset).await?; return Ok(MenuFlowResult::Exit); } PromptLineResult::Rejected => { - send_text(transport, CP437_INPUT_REJECT_LINE).await?; + send_text(transport, CP437_INPUT_REJECT_LINE, charset).await?; continue; } }; @@ -2639,7 +2854,12 @@ async fn run_files_flow( match action.as_str() { "D" => { if files.is_empty() { - send_text(transport, "No files are available for download.\r\n").await?; + send_text( + transport, + "No files are available for download.\r\n", + charset, + ) + .await?; continue; } run_file_download( @@ -2653,6 +2873,7 @@ async fn run_files_flow( idle_timeout, disconnect_reason, node_number, + charset, ) .await?; } @@ -2668,11 +2889,12 @@ async fn run_files_flow( idle_timeout, disconnect_reason, node_number, + charset, ) .await?; } _ => { - send_text(transport, "Unknown file command.\r\n").await?; + send_text(transport, "Unknown file command.\r\n", charset).await?; } } } @@ -2701,11 +2923,13 @@ async fn run_file_download( idle_timeout: Duration, disconnect_reason: &mut String, node_number: u16, + charset: TerminalCharset, ) -> ServeResult<()> { if user.security_level < area.download_security_level as i32 { send_text( transport, "Access denied. Security level too low for download.\r\n", + charset, ) .await?; return Ok(()); @@ -2718,6 +2942,7 @@ async fn run_file_download( true, false, "File number (blank to return): ", + charset, ) .await? { @@ -2728,11 +2953,11 @@ async fn run_file_download( } PromptLineResult::IdleTimeout => { *disconnect_reason = "idle_timeout".to_string(); - send_text(transport, "Idle timeout. Goodbye.\r\n").await?; + send_text(transport, "Idle timeout. Goodbye.\r\n", charset).await?; return Ok(()); } PromptLineResult::Rejected => { - send_text(transport, CP437_INPUT_REJECT_LINE).await?; + send_text(transport, CP437_INPUT_REJECT_LINE, charset).await?; return Ok(()); } }; @@ -2743,11 +2968,11 @@ async fn run_file_download( let file_index = match file_trimmed.parse::() { Ok(n) if n >= 1 && n <= files.len() => n - 1, _ => { - send_text(transport, "Invalid selection.\r\n").await?; + send_text(transport, "Invalid selection.\r\n", charset).await?; return Ok(()); } }; - let protocol = match prompt_transfer_protocol(transport, input, idle_timeout).await? { + let protocol = match prompt_transfer_protocol(transport, input, idle_timeout, charset).await? { Some(protocol) => protocol, None => return Ok(()), }; @@ -2755,13 +2980,13 @@ async fn run_file_download( let file = &files[file_index]; let file_path = file_entry_path(area, file); if !file_path.exists() { - send_text(transport, "File not found on disk.\r\n").await?; + send_text(transport, "File not found on disk.\r\n", charset).await?; return Ok(()); } let file_bytes = match std::fs::read(&file_path) { Ok(bytes) => bytes, Err(_) => { - send_text(transport, "Cannot read file.\r\n").await?; + send_text(transport, "Cannot read file.\r\n", charset).await?; return Ok(()); } }; @@ -2774,13 +2999,13 @@ async fn run_file_download( file.size_bytes, transfer_protocol_label(protocol) ), + charset, ) .await?; if protocol == TransferProtocol::XmodemCrc { send_text( transport, - "Start XMODEM receive in your terminal now. CRC is used when the terminal requests it.\r\n", - ) + "Start XMODEM receive in your terminal now. CRC is used when the terminal requests it.\r\n", charset) .await?; } @@ -2838,7 +3063,7 @@ async fn run_file_download( retry_count: i64::from(retry_count), }, )?; - send_text(transport, "\r\nTransfer complete.\r\n").await?; + send_text(transport, "\r\nTransfer complete.\r\n", charset).await?; debug!(node = %node_number, user_id = %user.id, file_id = %file.id, "caller downloaded file"); } Err(error) => { @@ -2864,7 +3089,12 @@ async fn run_file_download( retry_count: 0, }, )?; - send_text(transport, &format!("\r\nTransfer failed: {error}\r\n")).await?; + send_text( + transport, + &format!("\r\nTransfer failed: {error}\r\n"), + charset, + ) + .await?; debug!(node = %node_number, user_id = %user.id, file_id = %file.id, %error, "file transfer failed"); } } @@ -2883,16 +3113,18 @@ async fn run_file_upload( idle_timeout: Duration, disconnect_reason: &mut String, node_number: u16, + charset: TerminalCharset, ) -> ServeResult<()> { if user.security_level < area.upload_security_level as i32 { send_text( transport, "Access denied. Security level too low for upload.\r\n", + charset, ) .await?; return Ok(()); } - let protocol = match prompt_transfer_protocol(transport, input, idle_timeout).await? { + let protocol = match prompt_transfer_protocol(transport, input, idle_timeout, charset).await? { Some(protocol) => protocol, None => return Ok(()), }; @@ -2907,6 +3139,7 @@ async fn run_file_upload( idle_timeout, "Upload filename: ", disconnect_reason, + charset, ) .await?; let Some(filename) = filename else { @@ -2915,7 +3148,7 @@ async fn run_file_upload( let safe_name = match sanitize_filename(filename.trim()) { Ok(name) => name, Err(_) => { - send_text(transport, "Invalid upload filename.\r\n").await?; + send_text(transport, "Invalid upload filename.\r\n", charset).await?; return Ok(()); } }; @@ -2926,6 +3159,7 @@ async fn run_file_upload( true, false, "Declared size bytes (blank if unknown): ", + charset, ) .await?; if let PromptLineResult::Value(value) = declared { @@ -2934,7 +3168,7 @@ async fn run_file_upload( match trimmed.parse::() { Ok(size) => declared_size = Some(size), Err(_) => { - send_text(transport, "Invalid declared size.\r\n").await?; + send_text(transport, "Invalid declared size.\r\n", charset).await?; return Ok(()); } } @@ -2949,6 +3183,7 @@ async fn run_file_upload( "\r\nReady to receive via {}...\r\n", transfer_protocol_label(protocol) ), + charset, ) .await?; @@ -3000,7 +3235,12 @@ async fn run_file_upload( if let Some(limit) = upload_limit && payload.len() as u64 > limit { - send_text(transport, "Upload exceeds configured size limit.\r\n").await?; + send_text( + transport, + "Upload exceeds configured size limit.\r\n", + charset, + ) + .await?; record_file_transfer( db, FileTransferInput { @@ -3028,7 +3268,7 @@ async fn run_file_upload( let safe_name = match sanitize_filename(&requested_name) { Ok(name) => name, Err(_) => { - send_text(transport, "Invalid upload filename.\r\n").await?; + send_text(transport, "Invalid upload filename.\r\n", charset).await?; return Ok(()); } }; @@ -3087,6 +3327,7 @@ async fn run_file_upload( send_text( transport, "\r\nUpload complete. File is pending sysop review.\r\n", + charset, ) .await?; } @@ -3113,7 +3354,12 @@ async fn run_file_upload( retry_count: 0, }, )?; - send_text(transport, &format!("\r\nUpload failed: {error}\r\n")).await?; + send_text( + transport, + &format!("\r\nUpload failed: {error}\r\n"), + charset, + ) + .await?; } } Ok(()) @@ -3123,6 +3369,7 @@ async fn prompt_transfer_protocol( transport: &mut T, input: &mut InputSession, idle_timeout: Duration, + charset: TerminalCharset, ) -> ServeResult> { let protocol = match prompt_for_line( transport, @@ -3131,13 +3378,14 @@ async fn prompt_transfer_protocol( true, false, "Protocol: Z) ZMODEM X) XMODEM blank to return: ", + charset, ) .await? { PromptLineResult::Value(value) => value.trim().to_ascii_uppercase(), PromptLineResult::Disconnected | PromptLineResult::IdleTimeout => return Ok(None), PromptLineResult::Rejected => { - send_text(transport, CP437_INPUT_REJECT_LINE).await?; + send_text(transport, CP437_INPUT_REJECT_LINE, charset).await?; return Ok(None); } }; @@ -3147,7 +3395,7 @@ async fn prompt_transfer_protocol( "Z" => Ok(Some(TransferProtocol::Zmodem)), "X" => Ok(Some(TransferProtocol::XmodemCrc)), _ => { - send_text(transport, "Unsupported transfer protocol.\r\n").await?; + send_text(transport, "Unsupported transfer protocol.\r\n", charset).await?; Ok(None) } } @@ -3159,8 +3407,19 @@ async fn prompt_required_value( idle_timeout: Duration, prompt: &str, disconnect_reason: &mut String, + charset: TerminalCharset, ) -> ServeResult> { - match prompt_for_line(transport, input, idle_timeout, false, false, prompt).await? { + match prompt_for_line( + transport, + input, + idle_timeout, + false, + false, + prompt, + charset, + ) + .await? + { PromptLineResult::Value(value) => Ok(Some(value)), PromptLineResult::Disconnected => { *disconnect_reason = "caller_dropped_during_files".to_string(); @@ -3168,11 +3427,11 @@ async fn prompt_required_value( } PromptLineResult::IdleTimeout => { *disconnect_reason = "idle_timeout".to_string(); - send_text(transport, "Idle timeout. Goodbye.\r\n").await?; + send_text(transport, "Idle timeout. Goodbye.\r\n", charset).await?; Ok(None) } PromptLineResult::Rejected => { - send_text(transport, CP437_INPUT_REJECT_LINE).await?; + send_text(transport, CP437_INPUT_REJECT_LINE, charset).await?; Ok(None) } } @@ -3306,6 +3565,7 @@ fn record_file_transfer(db: &OxideDb, input: FileTransferInput<'_>) -> ServeResu async fn ensure_default_message_area( db: &OxideDb, transport: &mut T, + charset: TerminalCharset, ) -> ServeResult<()> { if !list_message_areas(db.db())?.is_empty() { return Ok(()); @@ -3322,7 +3582,12 @@ async fn ensure_default_message_area( None, ); warn!("failed to seed default message area: {error}"); - send_text(transport, "Messages are not available right now.\r\n").await?; + send_text( + transport, + "Messages are not available right now.\r\n", + charset, + ) + .await?; } Ok(()) } @@ -3341,11 +3606,17 @@ async fn display_message_list( db: &OxideDb, area: &MessageArea, messages: &[Message], + charset: TerminalCharset, ) -> ServeResult<()> { let author_aliases = message_author_aliases(db, messages); - send_text(transport, &format!("\r\n{} messages:\r\n", area.name)).await?; + send_text( + transport, + &format!("\r\n{} messages:\r\n", area.name), + charset, + ) + .await?; if messages.is_empty() { - send_text(transport, "No messages in this area.\r\n").await?; + send_text(transport, "No messages in this area.\r\n", charset).await?; return Ok(()); } @@ -3354,6 +3625,7 @@ async fn display_message_list( send_text( transport, &format!(" {}) {} (from {})\r\n", index + 1, message.subject, author), + charset, ) .await?; } @@ -3364,6 +3636,7 @@ async fn display_message( transport: &mut T, db: &OxideDb, message: &Message, + charset: TerminalCharset, ) -> ServeResult<()> { let author_aliases = message_author_aliases(db, std::slice::from_ref(message)); let author = author_alias_from_map(&author_aliases, &message.author_user_id); @@ -3377,6 +3650,7 @@ async fn display_message( "-".repeat(40), message.body ), + charset, ) .await } @@ -3388,35 +3662,45 @@ async fn prompt_for_message_index( disconnect_reason: &mut String, message_count: usize, prompt: &str, + charset: TerminalCharset, ) -> ServeResult { if message_count == 0 { - send_text(transport, "No messages are available.\r\n").await?; + send_text(transport, "No messages are available.\r\n", charset).await?; return Ok(MessageIndexPromptResult::Retry); } - let selected = - match prompt_for_line(transport, input, idle_timeout, false, false, prompt).await? { - PromptLineResult::Value(value) => value, - PromptLineResult::Disconnected => { - *disconnect_reason = "caller_dropped_during_messages".to_string(); - return Ok(MessageIndexPromptResult::Exit); - } - PromptLineResult::IdleTimeout => { - *disconnect_reason = "idle_timeout".to_string(); - send_text(transport, "Idle timeout. Goodbye.\r\n").await?; - return Ok(MessageIndexPromptResult::Exit); - } - PromptLineResult::Rejected => { - unreachable!("prompt_for_line handles rejected input internally"); - } - }; + let selected = match prompt_for_line( + transport, + input, + idle_timeout, + false, + false, + prompt, + charset, + ) + .await? + { + PromptLineResult::Value(value) => value, + PromptLineResult::Disconnected => { + *disconnect_reason = "caller_dropped_during_messages".to_string(); + return Ok(MessageIndexPromptResult::Exit); + } + PromptLineResult::IdleTimeout => { + *disconnect_reason = "idle_timeout".to_string(); + send_text(transport, "Idle timeout. Goodbye.\r\n", charset).await?; + return Ok(MessageIndexPromptResult::Exit); + } + PromptLineResult::Rejected => { + unreachable!("prompt_for_line handles rejected input internally"); + } + }; match selected.trim().parse::() { Ok(index) if (1..=message_count).contains(&index) => { Ok(MessageIndexPromptResult::Index(index - 1)) } Ok(_) | Err(_) => { - send_text(transport, "Invalid message number.\r\n").await?; + send_text(transport, "Invalid message number.\r\n", charset).await?; Ok(MessageIndexPromptResult::Retry) } } @@ -3426,18 +3710,20 @@ async fn prompt_for_message_body( transport: &mut T, input: &mut InputSession, idle_timeout: Duration, + charset: TerminalCharset, ) -> ServeResult { let mut output = Vec::new(); write_text_buffered( transport, "Enter message body. End with a single . on its own line.\r\n", &mut output, + charset, ) .await?; let mut lines = Vec::new(); loop { - match prompt_for_line(transport, input, idle_timeout, true, false, "> ").await? { + match prompt_for_line(transport, input, idle_timeout, true, false, "> ", charset).await? { PromptLineResult::Value(value) if value.trim() == "." => break, PromptLineResult::Value(value) => lines.push(value), PromptLineResult::Disconnected => return Ok(PromptLineResult::Disconnected), @@ -3503,13 +3789,23 @@ async fn prompt_for_line( allow_empty: bool, hide_input: bool, prompt: &str, + charset: TerminalCharset, ) -> ServeResult { let mut output = Vec::new(); loop { - write_text_buffered(transport, prompt, &mut output).await?; - match read_line_input(transport, input, idle_timeout, allow_empty, hide_input).await? { + write_text_buffered(transport, prompt, &mut output, charset).await?; + match read_line_input( + transport, + input, + idle_timeout, + allow_empty, + hide_input, + charset, + ) + .await? + { PromptLineResult::Rejected => { - send_text(transport, CP437_INPUT_REJECT_LINE).await?; + send_text(transport, CP437_INPUT_REJECT_LINE, charset).await?; } result => return Ok(result), } @@ -3522,6 +3818,7 @@ async fn read_line_input( idle_timeout: Duration, allow_empty: bool, hide_input: bool, + charset: TerminalCharset, ) -> ServeResult { let mut line = Vec::new(); let mut output = Vec::new(); @@ -3536,12 +3833,13 @@ async fn read_line_input( b'\0' | b'\n' if line.is_empty() => {} b'\r' if line.is_empty() && !allow_empty => {} b'\r' | b'\n' => { - write_text_buffered(transport, "\r\n", &mut output).await?; + write_text_buffered(transport, "\r\n", &mut output, charset).await?; break; } b'\x08' | b'\x7f' => { if line.pop().is_some() { - write_text_buffered(transport, "\x08 \x08", &mut output).await?; + write_text_buffered(transport, "\x08 \x08", &mut output, charset) + .await?; } } b'\t' => {} @@ -3549,13 +3847,14 @@ async fn read_line_input( line.push(raw); match raw { raw if hide_input && (raw.is_ascii_graphic() || raw == b' ') => { - write_text_buffered(transport, "*", &mut output).await? + write_text_buffered(transport, "*", &mut output, charset).await? } raw if !hide_input && (raw.is_ascii_graphic() || raw == b' ') => { write_text_buffered( transport, &String::from_utf8_lossy(&[raw]), &mut output, + charset, ) .await? } @@ -3793,7 +4092,7 @@ async fn send_login_flow( context, ) .await?; - send_menu_prompt(transport, login_menu, context).await + send_menu_prompt(transport, login_menu, context, capabilities.charset).await } async fn send_main_menu( @@ -3804,19 +4103,20 @@ async fn send_main_menu( context: &ScreenRenderContext, ) -> ServeResult<()> { send_screen(transport, config, &menu.screen.asset, capabilities, context).await?; - send_menu_prompt(transport, menu, context).await + send_menu_prompt(transport, menu, context, capabilities.charset).await } async fn send_menu_prompt( transport: &mut T, menu: &Menu, context: &ScreenRenderContext, + charset: TerminalCharset, ) -> ServeResult<()> { let prompt = menu .description .clone() .unwrap_or_else(|| "Command? ".to_string()); - let payload = expand_screen_runtime_tokens(encode_text(&prompt), context); + let payload = expand_screen_runtime_tokens(encode_text(&prompt, charset), context, charset); transport.write_all(&payload).await?; Ok(()) } @@ -3830,7 +4130,7 @@ async fn show_post_login_screens( for screen in &config.flow.post_login_screens { send_screen(transport, config, screen, capabilities, context).await?; } - send_text(transport, MAIN_MENU_POST_LOGIN).await + send_text(transport, MAIN_MENU_POST_LOGIN, capabilities.charset).await } async fn send_terminal_asset( @@ -3848,9 +4148,9 @@ async fn send_terminal_asset( capabilities, &error, ); - fallback_screen_payload(asset_name, &error) + fallback_screen_payload(asset_name, &error, capabilities.charset) }); - let payload = expand_screen_runtime_tokens(payload, context); + let payload = expand_screen_runtime_tokens(payload, context, capabilities.charset); transport.write_all(&payload).await?; Ok(()) } @@ -3869,9 +4169,9 @@ async fn send_logoff_screen( supports_ansi = capabilities.supports_ansi, "failed to load configured logoff screen; falling back to plain goodbye: {error}" ); - normalize_caller_line_endings(&encode_text("Goodbye.\r\n")) + normalize_caller_line_endings(&encode_text("Goodbye.\r\n", capabilities.charset)) }); - let payload = expand_screen_runtime_tokens(payload, context); + let payload = expand_screen_runtime_tokens(payload, context, capabilities.charset); let _ = transport.write_all(&payload).await; } @@ -3899,6 +4199,7 @@ fn load_terminal_asset_payload( } else { Ok(normalize_caller_line_endings(&encode_text( &oxidebbs_term::render_plain_text(&bytes), + capabilities.charset, ))) } } @@ -3913,7 +4214,7 @@ fn load_plain_terminal_asset_payload( match std::fs::read(&asset_path) { Ok(bytes) => { let text = String::from_utf8_lossy(&bytes); - return Ok(Some(encode_text(&text))); + return Ok(Some(encode_text(&text, capabilities.charset))); } Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => { @@ -3974,9 +4275,9 @@ async fn send_screen( ) -> ServeResult<()> { let payload = load_screen_payload(config, screen_key, *capabilities).unwrap_or_else(|error| { report_configured_asset_load_failure("screen", screen_key, *capabilities, &error); - fallback_screen_payload(screen_key, &error) + fallback_screen_payload(screen_key, &error, capabilities.charset) }); - let payload = expand_screen_runtime_tokens(payload, context); + let payload = expand_screen_runtime_tokens(payload, context, capabilities.charset); transport.write_all(&payload).await?; Ok(()) } @@ -4021,25 +4322,36 @@ fn load_screen_payload( match term_screen.load(&config.paths.screens, capabilities) { Ok(LoadedScreen::Ansi(bytes)) => Ok(normalize_caller_line_endings(&bytes)), - Ok(LoadedScreen::PlainText(text)) => Ok(normalize_caller_line_endings(&encode_text(&text))), + Ok(LoadedScreen::PlainText(text)) => Ok(normalize_caller_line_endings(&encode_text( + &text, + capabilities.charset, + ))), Err(error) => Err(error.to_string()), } } -fn fallback_screen_payload(screen_key: &str, details: &str) -> Vec { +fn fallback_screen_payload(screen_key: &str, details: &str, charset: TerminalCharset) -> Vec { let mut message = String::new(); let _ = writeln!(&mut message, "[{}]", screen_key); let _ = write!(&mut message, "{details}"); message.push_str(PROMPT_TERMINATOR); - normalize_caller_line_endings(&encode_text(&message)) + normalize_caller_line_endings(&encode_text(&message, charset)) } -fn expand_screen_runtime_tokens(payload: Vec, context: &ScreenRenderContext) -> Vec { - let payload = expand_oxide_display_codes(&payload, context); +fn expand_screen_runtime_tokens( + payload: Vec, + context: &ScreenRenderContext, + charset: TerminalCharset, +) -> Vec { + let payload = expand_oxide_display_codes(&payload, context, charset); expand_legacy_screen_tokens(&payload, context) } -fn expand_oxide_display_codes(payload: &[u8], context: &ScreenRenderContext) -> Vec { +fn expand_oxide_display_codes( + payload: &[u8], + context: &ScreenRenderContext, + charset: TerminalCharset, +) -> Vec { const MAX_DISPLAY_CODE_LENGTH: usize = 48; let mut output = Vec::with_capacity(payload.len()); @@ -4070,7 +4382,7 @@ fn expand_oxide_display_codes(payload: &[u8], context: &ScreenRenderContext) -> let display_code = &payload[cursor + 1..end]; if display_code.len() <= MAX_DISPLAY_CODE_LENGTH - && let Some(expanded) = expand_display_code(display_code, context) + && let Some(expanded) = expand_display_code(display_code, context, charset) { output.extend_from_slice(&expanded); cursor = end + 1; @@ -4084,7 +4396,11 @@ fn expand_oxide_display_codes(payload: &[u8], context: &ScreenRenderContext) -> output } -fn expand_display_code(display_code: &[u8], context: &ScreenRenderContext) -> Option> { +fn expand_display_code( + display_code: &[u8], + context: &ScreenRenderContext, + charset: TerminalCharset, +) -> Option> { if display_code.is_empty() || !display_code .iter() @@ -4096,7 +4412,7 @@ fn expand_display_code(display_code: &[u8], context: &ScreenRenderContext) -> Op let display_code = std::str::from_utf8(display_code).ok()?; let (name, format) = display_code.split_once(':').unwrap_or((display_code, "")); let value = display_code_value(&name.to_ascii_uppercase(), context)?; - format_display_code_value(encode_text(&value), format) + format_display_code_value(encode_text(&value, charset), format) } fn display_code_value(name: &str, context: &ScreenRenderContext) -> Option { @@ -4215,6 +4531,7 @@ mod display_code_tests { let output = expand_screen_runtime_tokens( b"Node @NODE:03@/@NT:03@ User @USER:-8@ Sec @SEC:03@".to_vec(), &display_context(), + TerminalCharset::Cp437, ); assert_eq!(output, b"Node 002/008 User Cmdr Sec 010"); @@ -4225,6 +4542,7 @@ mod display_code_tests { let output = expand_screen_runtime_tokens( b"Email sysop@example.com @@ @NOPE@ @BBS@".to_vec(), &display_context(), + TerminalCharset::Cp437, ); assert_eq!(output, b"Email sysop@example.com @ @NOPE@ Blackboard"); @@ -4250,8 +4568,9 @@ async fn send_text_buffered( transport: &mut T, message: &str, output: &mut Vec, + charset: TerminalCharset, ) -> ServeResult<()> { - encode_text_into(message, output); + encode_text_into(message, output, charset); *output = normalize_caller_line_endings(output); transport.write_all(output).await?; output.clear(); @@ -4262,13 +4581,18 @@ async fn write_text_buffered( transport: &mut T, message: &str, output: &mut Vec, + charset: TerminalCharset, ) -> ServeResult<()> { - send_text_buffered(transport, message, output).await + send_text_buffered(transport, message, output, charset).await } -async fn send_text(transport: &mut T, message: &str) -> ServeResult<()> { +async fn send_text( + transport: &mut T, + message: &str, + charset: TerminalCharset, +) -> ServeResult<()> { let mut output = Vec::new(); - send_text_buffered(transport, message, &mut output).await?; + send_text_buffered(transport, message, &mut output, charset).await?; Ok(()) } @@ -4276,29 +4600,47 @@ async fn process_runtime_commands( transport: &mut T, commands: RuntimeNodeCommands, disconnect_reason: &mut String, + charset: TerminalCharset, ) -> ServeResult { let mut output = Vec::new(); for message in commands.messages { - send_text_buffered(transport, &format!("\r\n{message}\r\n"), &mut output).await?; + send_text_buffered( + transport, + &format!("\r\n{message}\r\n"), + &mut output, + charset, + ) + .await?; } if let Some(reason) = commands.disconnect_reason { *disconnect_reason = reason; - send_text_buffered(transport, "\r\nDisconnected by sysop.\r\n", &mut output).await?; + send_text_buffered( + transport, + "\r\nDisconnected by sysop.\r\n", + &mut output, + charset, + ) + .await?; return Ok(true); } Ok(false) } -fn encode_text(text: &str) -> Vec { +fn encode_text(text: &str, charset: TerminalCharset) -> Vec { let mut output = Vec::new(); - encode_text_into(text, &mut output); + encode_text_into(text, &mut output, charset); output } -fn encode_text_into(text: &str, output: &mut Vec) { +fn encode_text_into(text: &str, output: &mut Vec, charset: TerminalCharset) { output.clear(); + if let TerminalCharset::Petscii = charset { + output.extend_from_slice(&render_petscii_lossy(text)); + return; + } + if text.is_ascii() { output.reserve(text.len()); output.extend_from_slice(text.as_bytes()); @@ -4307,14 +4649,18 @@ fn encode_text_into(text: &str, output: &mut Vec) { match encode_cp437(text) { Ok(bytes) => output.extend_from_slice(&bytes), - Err(_) => encode_text_lossy_into(text, output), + Err(_) => encode_text_lossy_into(text, output, charset), } } -fn encode_text_lossy_into(text: &str, output: &mut Vec) { +fn encode_text_lossy_into(text: &str, output: &mut Vec, charset: TerminalCharset) { output.clear(); output.reserve(text.len()); for character in text.chars() { + if let TerminalCharset::Petscii = charset { + output.push(char_to_petscii_byte(character).unwrap_or(b'?')); + continue; + } let mut buffer = [0_u8; 4]; let encoded = character.encode_utf8(&mut buffer); match encode_cp437(encoded) { @@ -4564,7 +4910,7 @@ async fn send_menu_help( } help.push_str("\r\n"); - send_text(transport, &help).await + send_text(transport, &help, capabilities.charset).await } fn menu_entry_visible_to_security_level( @@ -5345,7 +5691,7 @@ mod tests { #[test] fn fallback_payload_includes_context() { - let payload = fallback_screen_payload("login", "missing file"); + let payload = fallback_screen_payload("login", "missing file", TerminalCharset::Cp437); let decoded = String::from_utf8_lossy(&payload); assert!(decoded.contains("[login]")); @@ -5655,11 +6001,37 @@ mod tests { fn ascii_text_encodes_without_cp437_lookup() { let mut output = Vec::new(); - encode_text_into("Main menu? ", &mut output); + encode_text_into("Main menu? ", &mut output, TerminalCharset::Cp437); assert_eq!(output, b"Main menu? "); } + #[test] + fn petscii_charset_encodes_text_to_petscii_bytes() { + let c64 = TerminalCapabilities::c64(); + assert_eq!(c64.charset, TerminalCharset::Petscii); + + assert_eq!( + encode_text("ABC", TerminalCharset::Petscii), + [0x41, 0x42, 0x43] + ); + + let box_drawing = encode_text("\u{250c}\u{2500}\u{2510}", TerminalCharset::Petscii); + assert_eq!(box_drawing, [0xb4, 0xb1, 0xb5]); + } + + #[test] + fn petscii_lossy_replaces_unsupported_glyphs_instead_of_failing() { + let bytes = encode_text("C64 \u{1f680}", TerminalCharset::Petscii); + assert_eq!(bytes, [b'C', b'6', b'4', b' ', b'?']); + } + + #[test] + fn non_petscii_charset_keeps_cp437_box_drawing_bytes() { + let bytes = encode_text("\u{2554}\u{2550}", TerminalCharset::Cp437); + assert_eq!(bytes, [0xc9, 0xcd]); + } + #[test] fn ascii_is_cp437_compatible() { assert!(is_cp437_compatible("Main menu? 123.")); @@ -5699,21 +6071,24 @@ mod tests { let text = "┌─┐"; assert_eq!( - encode_text(text), + encode_text(text, TerminalCharset::Cp437), encode_cp437(text).expect("box drawing is CP437-compatible") ); } #[test] fn generated_output_replaces_unencodable_text_with_question_mark() { - assert_eq!(encode_text("Diagnostic 🚀"), b"Diagnostic ?"); + assert_eq!( + encode_text("Diagnostic 🚀", TerminalCharset::Cp437), + b"Diagnostic ?" + ); } #[tokio::test] async fn send_text_normalizes_bare_lf_to_crlf() { let (mut transport, mut client) = LoopbackTransport::new(); - send_text(&mut transport, "One\nTwo\n") + send_text(&mut transport, "One\nTwo\n", TerminalCharset::Cp437) .await .expect("send text"); @@ -5878,6 +6253,7 @@ mod tests { Duration::from_secs(1), &mut disconnect_reason, 1, + TerminalCharset::Cp437, ), ) .await; @@ -5952,6 +6328,7 @@ mod tests { Duration::from_secs(1), &mut disconnect_reason, 1, + TerminalCharset::Cp437, ), ) .await; @@ -6161,6 +6538,7 @@ mod tests { Duration::from_secs(1), true, false, + TerminalCharset::Cp437, ) .await .expect("read"); @@ -6184,6 +6562,7 @@ mod tests { Duration::from_secs(1), false, false, + TerminalCharset::Cp437, ) .await .expect("read"); @@ -6209,6 +6588,7 @@ mod tests { Duration::from_secs(1), false, false, + TerminalCharset::Cp437, ) .await .expect("read"); @@ -6232,6 +6612,7 @@ mod tests { Duration::from_secs(1), false, true, + TerminalCharset::Cp437, ) .await .expect("read"); @@ -6255,6 +6636,7 @@ mod tests { Duration::from_secs(1), false, false, + TerminalCharset::Cp437, ) .await .expect("read"); @@ -6278,6 +6660,7 @@ mod tests { Duration::from_secs(1), false, false, + TerminalCharset::Cp437, ) .await .expect("read"); @@ -6351,6 +6734,7 @@ mod tests { Duration::from_secs(1), true, false, + TerminalCharset::Cp437, ) .await .expect("read"); @@ -6374,6 +6758,7 @@ mod tests { Duration::from_secs(1), true, false, + TerminalCharset::Cp437, ) .await .expect("read"); @@ -6393,9 +6778,14 @@ mod tests { .write_bytes(b"First line\r\n\r\nLast line\r\n.\r\n") .expect("write body"); - let value = prompt_for_message_body(&mut transport, &mut input, Duration::from_secs(1)) - .await - .expect("read body"); + let value = prompt_for_message_body( + &mut transport, + &mut input, + Duration::from_secs(1), + TerminalCharset::Cp437, + ) + .await + .expect("read body"); match value { PromptLineResult::Value(value) => assert_eq!(value, "First line\r\n\r\nLast line"), @@ -6455,9 +6845,14 @@ mod tests { disconnect_reason: &mut disconnect_reason, }; - let result = run_login_flow(&mut transport, &mut input, &mut state) - .await - .expect("login flow"); + let result = run_login_flow( + &mut transport, + &mut input, + &mut state, + TerminalCharset::Cp437, + ) + .await + .expect("login flow"); let output = String::from_utf8_lossy(&client.read_output_bytes()).into_owned(); assert!(matches!(result, AuthFlowResult::Success)); @@ -6646,9 +7041,15 @@ mod tests { ]; let (mut transport, mut client) = LoopbackTransport::new(); - display_message_list(&mut transport, &db, &area, &messages) - .await - .expect("display"); + display_message_list( + &mut transport, + &db, + &area, + &messages, + TerminalCharset::Cp437, + ) + .await + .expect("display"); let output = String::from_utf8_lossy(&client.read_output_bytes()).to_string(); assert!(output.contains("1) One (from alice)")); @@ -6666,9 +7067,15 @@ mod tests { )]; let (mut transport, mut client) = LoopbackTransport::new(); - display_message_list(&mut transport, &db, &area, &messages) - .await - .expect("display"); + display_message_list( + &mut transport, + &db, + &area, + &messages, + TerminalCharset::Cp437, + ) + .await + .expect("display"); let output = String::from_utf8_lossy(&client.read_output_bytes()).to_string(); assert!(output.contains("1) Missing (from Unknown)")); @@ -6916,9 +7323,14 @@ mod tests { idle_timeout: Duration::from_secs(1), disconnect_reason: &mut disconnect_reason, }; - let result = run_login_flow(&mut transport, &mut input, &mut state) - .await - .expect("login flow"); + let result = run_login_flow( + &mut transport, + &mut input, + &mut state, + TerminalCharset::Cp437, + ) + .await + .expect("login flow"); let output = client_task.await.expect("client task"); (result, output, authenticated_user) } @@ -6968,9 +7380,14 @@ mod tests { idle_timeout: Duration::from_secs(1), disconnect_reason: &mut disconnect_reason, }; - let result = run_new_user_flow(&mut transport, &mut input, &mut state) - .await - .expect("new user flow"); + let result = run_new_user_flow( + &mut transport, + &mut input, + &mut state, + TerminalCharset::Cp437, + ) + .await + .expect("new user flow"); let output = client_task.await.expect("client task"); (result, output) } diff --git a/crates/oxidebbs-server/src/setup.rs b/crates/oxidebbs-server/src/setup.rs index cbdd0d5..10ba304 100644 --- a/crates/oxidebbs-server/src/setup.rs +++ b/crates/oxidebbs-server/src/setup.rs @@ -644,7 +644,7 @@ fn generated_terminal_profiles() -> BTreeMap "cp437", Self::Ascii => "ascii", + Self::Petscii => "petscii", Self::PetsciiAsciiFallback => "petscii_ascii_fallback", } } @@ -161,7 +163,7 @@ impl TerminalCapabilities { supports_color: false, width: 40, height: 25, - charset: TerminalCharset::PetsciiAsciiFallback, + charset: TerminalCharset::Petscii, line_endings: LineEndingMode::Crlf, backspace_mode: BackspaceMode::BackspaceOrDelete, output_pacing: Some(OutputPacing { @@ -506,6 +508,127 @@ pub fn char_to_cp437_byte(character: char) -> Option { .map(|index| index + 0x80) } +/// PETSCII (Commodore) decode table for bytes `0x00..=0x7F`. +/// +/// This implements the C64 "upper case and graphics" screen set. Codes in the +/// `0x01..=0x1A` / `0x41..=0x5A` / `0x61..=0x7A` ranges carry upper/lower case +/// text, while `0x0D`/`0x0A` are newline controls and the `0xA0..=0xDF` range +/// carries the standard C64 line-drawing and block graphics. Glyph fidelity for +/// the graphics range follows the C64 ROM approximations; see ADR 0034. +const PETSCII_LOWER: [char; 128] = [ + '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', + '\u{FFFD}', '\u{FFFD}', '\n', '\u{FFFD}', '\u{FFFD}', '\n', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', + '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', + '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{25C4}', '\u{25BA}', ' ', '!', + '"', '#', '$', '%', '&', '\'', '(', ')', '*', '+', ',', '-', '.', '/', '0', '1', '2', '3', '4', + '5', '6', '7', '8', '9', ':', ';', '<', '=', '>', '?', '@', 'A', 'B', 'C', 'D', 'E', 'F', 'G', + 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', + '[', '\u{00A3}', ']', '\u{2191}', '\u{2190}', '`', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', + 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '{', '|', + '}', '~', '\u{007F}', +]; + +/// PETSCII (Commodore) decode table for bytes `0x80..=0xFF`. +const PETSCII_UPPER: [char; 128] = [ + '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', + '\u{FFFD}', '\u{FFFD}', '\n', '\u{FFFD}', '\u{FFFD}', '\n', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', + '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', + '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{FFFD}', '\u{25C4}', '\u{25BA}', '\u{2588}', + '\u{2589}', '\u{258A}', '\u{258B}', '\u{258C}', '\u{258D}', '\u{258E}', '\u{258F}', '\u{2590}', + '\u{2580}', '\u{2590}', '\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2592}', '\u{2586}', + '\u{2500}', '\u{2588}', '\u{2502}', '\u{250C}', '\u{2510}', '\u{2514}', '\u{2518}', '\u{251C}', + '\u{2524}', '\u{252C}', '\u{2534}', '\u{253C}', '\u{2580}', '\u{2584}', '\u{2580}', '\u{2588}', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', + 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '\u{2591}', '\u{2592}', '\u{25C4}', '\u{25BA}', '\u{2500}', + '\u{2588}', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', + 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '\u{2191}', '\u{2193}', '\u{2190}', + '\u{2192}', '\u{25C6}', +]; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct PetsciiEncodeError { + character: char, + byte_index: usize, +} + +impl PetsciiEncodeError { + pub fn new(character: char, byte_index: usize) -> Self { + Self { + character, + byte_index, + } + } + + pub fn character(&self) -> char { + self.character + } + + pub fn byte_index(&self) -> usize { + self.byte_index + } +} + +impl fmt::Display for PetsciiEncodeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "character {:?} at byte index {} is not representable in PETSCII", + self.character, self.byte_index + ) + } +} + +impl Error for PetsciiEncodeError {} + +pub fn decode_petscii(bytes: &[u8]) -> String { + bytes.iter().copied().map(petscii_byte_to_char).collect() +} + +pub fn petscii_byte_to_char(byte: u8) -> char { + if byte < 0x80 { + PETSCII_LOWER[usize::from(byte)] + } else { + PETSCII_UPPER[usize::from(byte - 0x80)] + } +} + +pub fn char_to_petscii_byte(character: char) -> Option { + if character.is_ascii() { + return Some(character as u8); + } + + if let Some(index) = PETSCII_LOWER.iter().position(|mapped| *mapped == character) { + return u8::try_from(index).ok(); + } + + PETSCII_UPPER + .iter() + .position(|mapped| *mapped == character) + .map(|index| index as u8 + 0x80) +} + +/// Encode Unicode text to PETSCII bytes, failing on characters that have no +/// PETSCII representation. Callers that must never fail should use +/// [`render_petscii_lossy`]. +pub fn render_petscii(input: &str) -> Result, PetsciiEncodeError> { + let mut bytes = Vec::with_capacity(input.len()); + for (byte_index, character) in input.char_indices() { + let byte = char_to_petscii_byte(character) + .ok_or_else(|| PetsciiEncodeError::new(character, byte_index))?; + bytes.push(byte); + } + Ok(bytes) +} + +/// Encode Unicode text to PETSCII bytes, replacing any unsupported character +/// with `?` per the ADR 0034 replacement policy. +pub fn render_petscii_lossy(input: &str) -> Vec { + input + .chars() + .map(|character| char_to_petscii_byte(character).unwrap_or(b'?')) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -564,7 +687,7 @@ mod tests { assert_eq!(capabilities.height, 25); assert!(!capabilities.supports_ansi); assert!(!capabilities.supports_color); - assert_eq!(capabilities.charset, TerminalCharset::PetsciiAsciiFallback); + assert_eq!(capabilities.charset, TerminalCharset::Petscii); assert_eq!(capabilities.line_endings, LineEndingMode::Crlf); assert_eq!( capabilities.backspace_mode, @@ -714,4 +837,61 @@ mod tests { ); assert!(lines.iter().all(|line| line.len() <= 20)); } + + #[test] + fn petscii_charset_reports_petscii_name() { + assert_eq!(TerminalCharset::Petscii.as_str(), "petscii"); + } + + #[test] + fn decodes_petscii_text_bytes() { + assert_eq!(decode_petscii(&[0x41, 0x42, 0x43]), "ABC"); + assert_eq!(decode_petscii(&[0x61, 0x62, 0x63]), "abc"); + assert_eq!(decode_petscii(&[0xc1, 0xc2]), "AB"); + } + + #[test] + fn encodes_text_to_petscii_bytes() { + assert_eq!(render_petscii("ABC").unwrap(), [0x41, 0x42, 0x43]); + assert_eq!(render_petscii("abc").unwrap(), [0x61, 0x62, 0x63]); + } + + #[test] + fn round_trips_petscii_text() { + let text = "Hello, BBS caller! 123"; + let encoded = render_petscii(text).expect("ascii text is petscii-safe"); + assert_eq!(decode_petscii(&encoded), text); + } + + #[test] + fn decodes_petscii_box_drawing_graphics() { + assert_eq!( + decode_petscii(&[0xb4, 0xb1, 0xb5, 0xb6, 0xb1, 0xb7, 0xb3]), + "\u{250c}\u{2500}\u{2510}\u{2514}\u{2500}\u{2518}\u{2502}" + ); + } + + #[test] + fn encodes_box_drawing_to_petscii_graphics() { + let encoded = render_petscii("\u{250c}\u{2500}\u{2510}\u{251c}\u{253c}\u{2524}").unwrap(); + assert_eq!(encoded, [0xb4, 0xb1, 0xb5, 0xb8, 0xbc, 0xb9]); + } + + #[test] + fn reports_unrepresentable_petscii_character() { + let error = render_petscii("BBS \u{1f680}").expect_err("rocket is not PETSCII"); + assert_eq!(error.character(), '\u{1f680}'); + assert_eq!(error.byte_index(), 4); + } + + #[test] + fn petscii_lossy_replaces_unsupported_glyphs() { + assert_eq!(render_petscii_lossy("A\u{1f680}B"), [0x41, b'?', 0x42]); + } + + #[test] + fn maps_petscii_newline_controls_to_logical_newline() { + assert_eq!(decode_petscii(&[0x4f, 0x0d, 0x50]), "O\nP"); + assert_eq!(decode_petscii(&[0x4f, 0x0a, 0x50]), "O\nP"); + } } diff --git a/design/TASKS.md b/design/TASKS.md index 3914d34..0cbed77 100644 --- a/design/TASKS.md +++ b/design/TASKS.md @@ -4,6 +4,37 @@ This file tracks active release work and near-term follow-up items. It is not a replacement for `design/ROADMAP.md`, `docs/about/changelog.md`, or ADRs; it is a short operational checklist for work that needs explicit closure. +## v1.3.0 Release Work + +Active work follows [`design/RELEASE_v1_3_PLAN.md`](./RELEASE_v1_3_PLAN.md). +Phase status lives in that plan; this section tracks concrete closure items. + +| Phase | Title | Status | +| --- | --- | --- | +| P0 | Scope freeze and ADR baseline | Partial (ADR 0034 accepted; ADRs 0035/0036 still proposed) | +| P1 | Documentation reconciliation and task tracker update | Active | +| P2 | C64/PETSCII terminal completion | Complete | +| P3 | Manual terminal profile persistence | Planned | +| P4 | Caller transfer protocol decision and expansion | Blocked (ADR 0035) | +| P5 | Door drop-file compatibility expansion | Planned | +| P6 | FTN/OxideNet interoperability hardening | Blocked (ADR 0036 / operator feedback) | +| P7 | OxideNet topology and public-network expansion | Planned | +| P8 | Final integration and release readiness | Planned | + +### P2: C64/PETSCII Terminal Completion + +- [x] Author ADR 0034 (PETSCII translation and terminal-profile persistence policy). +- [x] Add full PETSCII encode/decode tables and tests in `oxidebbs-term`. +- [x] Add `TerminalCharset::Petscii` and make the C64 profile default to it. +- [x] Route C64 profile output through PETSCII-aware rendering via the + charset-aware `encode_text_into` chokepoint. +- [x] Keep CP437/ANSI behavior unchanged for ANSI and plain 80-column callers. +- [x] Add config support for `charset = "petscii"` and update example/default C64 + config (`petscii_ascii_fallback` remains supported for operators). +- [x] Add PETSCII round-trip, box-drawing, lossy-replacement, and C64 encoding + tests; keep 40-column wrapping tests stable. +- [x] Run `./scripts/dev-check.sh` (fmt/check/test/clippy green). + ## v1.2.2 Docker Publication Patch - [x] Bump OxideBBS release metadata to `1.2.2`. @@ -73,9 +104,10 @@ re-opening scope decisions. - [x] Add 40-column plain fallback asset slots and starter fallback assets for C64/plain narrow callers. - [x] Cover CR/LF and `0x08`/`0x7f` input behavior in tests. -- [ ] Implement full PETSCII encode/decode rendering beyond ASCII fallback. +- [x] Implement full PETSCII encode/decode rendering beyond ASCII fallback. + *(v1.3 P2; see ADR 0034.)* - [ ] Persist manual terminal profile selection in user/account settings once - the user schema has a terminal preference field. + the user schema has a terminal preference field. *(v1.3 P3.)* ## Web Caller Terminal diff --git a/design/adr/0034-petscii-translation-and-terminal-profile-persistence.md b/design/adr/0034-petscii-translation-and-terminal-profile-persistence.md new file mode 100644 index 0000000..df55120 --- /dev/null +++ b/design/adr/0034-petscii-translation-and-terminal-profile-persistence.md @@ -0,0 +1,80 @@ +# ADR 0034: PETSCII Translation And Terminal-Profile Persistence Policy + +## Status + +Accepted + +## Context + +OxideBBS documents C64 / C64 Ultimate / PETSCII-friendly 40-column callers as a +supported terminal profile, but until now the C64 profile only provided an +ASCII fallback charset (`PetsciiAsciiFallback`). Box-drawing and high-bit +glyphs were never translated to PETSCII, so a real C64 caller received CP437 or +UTF-8 bytes its character set could not render. + +`design/RELEASE_v1_3_PLAN.md` phase P2 requires full PETSCII encode/decode +rendering beyond the ASCII fallback, and phase P3 requires persisting a manual +terminal-profile preference. Both need a documented translation and persistence +policy so implementation does not invent ad-hoc behavior. + +The caller UI remains byte-oriented (not Unicode-first). Output text is built as +Unicode `String` inside the server, then encoded to wire bytes by the terminal's +charset. Binary file-transfer data and telnet negotiation bytes must never be +re-encoded. + +## Decision + +### PETSCII character set and translation + +- Implement the C64 "upper case and graphics" PETSCII screen set in + `oxidebbs-term` as a full 256-entry decode table (`decode_petscii`) plus a + reverse encode path (`encode_petscii`, `render_petscii`, `render_petscii_lossy`). +- Printable ASCII (`0x20`-`0x7E`) maps to itself. Letters are accepted in the + unshifted (`0x41`-`0x5A`, `0x61`-`0x7A`) and shifted (`0xC1`-`0xDA`, + `0xE1`-`0xFA`) PETSCII ranges. `0x0D` and `0x0A` decode to a logical newline. +- The `0xA0`-`0xDF` graphics range carries the standard C64 line-drawing and + block glyphs (corners, tees, crosses, shade and half blocks). Glyph fidelity + for the graphics range follows C64 ROM approximations; the exact visual on a + given emulator is not guaranteed. +- Unsupported source characters use a replacement policy: lossy encoding + replaces them with `?`. Strict encoding (`render_petscii`) returns + `PetsciiEncodeError` so callers that must never fail can choose lossy. + +### Charset selection + +- Add `TerminalCharset::Petscii` (config string `"petscii"`) as the real + PETSCII charset. `PetsciiAsciiFallback` (`"petscii_ascii_fallback"`) remains + a supported value for operators that want the historical ASCII-only behavior. +- The built-in C64 profile (`TerminalCapabilities::c64()`) and the default + generated/example C64 config now select `Petscii`. + +### Routing + +- Caller output text is charset-aware at the central `encode_text_into` + chokepoint. When the charset is `Petscii`, text is encoded to PETSCII bytes; + otherwise CP437/ASCII behavior is unchanged for ANSI and plain callers. +- Binary file-transfer writes and telnet IAC negotiation bytes bypass text + encoding and are never PETSCII-converted, so transfers and negotiation remain + intact for C64 callers. + +### Manual profile persistence (P3 scope) + +- A persisted terminal-profile preference is the highest-priority source when + present, overriding unreliable telnet detection. The fallback order is: + persisted user preference > telnet terminal-type detection > configured + default profile. +- Existing users migrate with no forced preference; detection behaves exactly + as before until a preference is set. +- The user/account schema migration, onboarding flow, and sysop edit support + are tracked under P3 and are not required for P2's PETSCII rendering. + +## Consequences + +- C64 callers now receive PETSCII-encoded text for menus, messages, file lists, + and logoff flow. Existing ANSI/CP437 and plain-ASCII snapshots are unchanged. +- `PetsciiAsciiFallback` remains available but is no longer the C64 default; + operators that depended on it must set `charset = "petscii_ascii_fallback"`. +- Text that contains glyphs with no PETSCII representation is lossily replaced + with `?` rather than failing the caller session. +- Profile persistence work can proceed independently in P3 using the fallback + order above. diff --git a/docs/about/changelog.md b/docs/about/changelog.md index 228e56a..2f1bdc7 100644 --- a/docs/about/changelog.md +++ b/docs/about/changelog.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `oxidebbs-core::constants` to centralise default configuration values such as + the default door time limit and the default BinkP port. +- Full PETSCII encode/decode for the C64 terminal profile in `oxidebbs-term`, + replacing the previous ASCII-only fallback. The C64 "upper case and graphics" + screen set is covered for printable text, shifted/unshifted letters, CR/LF + newlines, and standard C64 line-drawing and block graphics, with a + lossy-replacement policy for unsupported glyphs. See ADR 0034. +- `TerminalCharset::Petscii` (config string `"petscii"`) and config support for + selecting it; `petscii_ascii_fallback` remains supported for operators. + +### Changed +- The built-in C64 terminal profile and default/example C64 config now select + real PETSCII rendering instead of the ASCII fallback. +- Caller output is now charset-aware at the central text-encoding chokepoint, so + C64 callers receive PETSCII-encoded menus, messages, file lists, and logoff + screens. ANSI/CP437 and plain-ASCII behavior is unchanged, and binary + file-transfer and telnet negotiation bytes are never re-encoded. + ## [1.2.2] - 2026-06-07 ### Added From 47c98974c3badeef53515c2da82ae953f99e47eb Mon Sep 17 00:00:00 2001 From: Steven Hildreth Date: Wed, 5 Aug 2026 17:17:57 -0500 Subject: [PATCH 7/9] chore: update workspace versioning and dependencies - Set versioning to workspace-based for multiple crates: oxidebbs-core, oxidebbs-db, oxidebbs-door, oxidebbs-ftn, oxidebbs-network, oxidebbs-oxidenet, oxidebbs-server, oxidebbs-sysop, oxidebbs-telnet, oxidebbs-term, oxidebbs-transfer. - Updated software version to 1.3.0 in relevant test cases and documentation. - Improved ANSI parser to handle ECMA-48 private parameter bytes and bounded accumulation for parameters, intermediates, and OSC payloads. - Enhanced CP437 encoding/decoding to support low-range glyphs and preserve structural control bytes. - Fixed transaction handling in database migrations and insert operations to ensure atomicity. - Updated documentation to reflect architectural changes and versioning guide. - Marked stress test for 50,000 entries as ignored to maintain default test speed. --- .github/rust-code-generation/SKILL.md | 4 +- .github/workflows/release.yml | 4 +- AGENTS.md | 44 +++-- Cargo.lock | 191 ++-------------------- Cargo.toml | 8 +- VERSION | 2 +- compose.yaml | 2 +- crates/oxidebbs-binkp/Cargo.toml | 2 +- crates/oxidebbs-core/Cargo.toml | 3 +- crates/oxidebbs-db/Cargo.toml | 2 +- crates/oxidebbs-db/src/db_writer.rs | 4 +- crates/oxidebbs-db/src/migrations.rs | 42 +++-- crates/oxidebbs-db/src/network_repo.rs | 59 ++++++- crates/oxidebbs-db/src/oxidenet_repo.rs | 4 +- crates/oxidebbs-door/Cargo.toml | 2 +- crates/oxidebbs-ftn/Cargo.toml | 2 +- crates/oxidebbs-network/Cargo.toml | 2 +- crates/oxidebbs-oxidenet/Cargo.toml | 2 +- crates/oxidebbs-oxidenet/src/lib.rs | 2 +- crates/oxidebbs-server/Cargo.toml | 4 +- crates/oxidebbs-server/src/commands/db.rs | 4 +- crates/oxidebbs-sysop/Cargo.toml | 6 +- crates/oxidebbs-telnet/Cargo.toml | 2 +- crates/oxidebbs-term/Cargo.toml | 2 +- crates/oxidebbs-term/src/ansi_parser.rs | 142 ++++++++++++++-- crates/oxidebbs-term/src/lib.rs | 119 +++++++++++++- crates/oxidebbs-transfer/Cargo.toml | 2 +- design/ARCHITECTURE.md | 14 +- design/SPEC.md | 25 ++- design/VERSIONING_GUIDE.md | 9 +- docs/OXDOOR_FORMAT_V1.md | 2 +- docs/about/changelog.md | 28 ++++ docs/project/docker.md | 14 +- package-lock.json | 4 +- package.json | 2 +- scripts/bump-version.sh | 5 + 36 files changed, 475 insertions(+), 290 deletions(-) diff --git a/.github/rust-code-generation/SKILL.md b/.github/rust-code-generation/SKILL.md index ef0feab..1bc21f6 100644 --- a/.github/rust-code-generation/SKILL.md +++ b/.github/rust-code-generation/SKILL.md @@ -65,11 +65,9 @@ Before writing code: In this repository, consult these files when relevant: -- `AGENTS.md` -- `.github/copilot-instructions.md` +- `AGENTS.md` (including the `scripts/dev-check.sh` validation gate it defines) - `design/PRD.md` - `design/SPEC.md` -- `design/TESTING_STRATEGY.md` - relevant files in `design/adr/` ## Rust Generation Rules diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2dcb4b0..88e31e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: tag_name: - description: Release tag to validate or publish, such as v1.2.2 + description: Release tag to validate or publish, such as v1.3.0 required: true source_ref: description: Git ref to build; dry runs default to the workflow commit, publishes default to tag_name @@ -59,7 +59,7 @@ jobs: fi if [[ "${tag}" != v* ]]; then - echo "tag_name must include the leading v, such as v1.2.2" >&2 + echo "tag_name must include the leading v, such as v1.3.0" >&2 exit 1 fi diff --git a/AGENTS.md b/AGENTS.md index 85d8bd7..b222376 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,26 +50,38 @@ sudo apt-get install -y clang libclang-dev ``` crates/ - oxidebbs-server/ # binary entrypoint (main.rs) - oxidebbs-core/ # domain: sessions, menus, users, permissions - oxidebbs-term/ # ANSI/CP437 rendering, AnsiBuffer, CP437 encode/decode - oxidebbs-telnet/ # telnet transport and negotiation (stub) - oxidebbs-db/ # DecentDB repository layer, OxideDb, schema init - oxidebbs-door/ # door definitions, drop files, runners (stub) - oxidebbs-sysop/ # local sysop admin TUI/CLI (stub) -design/ # ARCHITECTURE.md, SPEC.md, PRD.md, TASKS.md, ADRs -docs/ # VitePress documentation site (Node/npm) -config/ # oxidebbs.example.toml -scripts/ # dev-check.sh + oxidebbs-server/ # binary entrypoint: config, telnet/serial serving, web admin UI, web terminal, binkp listener, sysop CLI + oxidebbs-core/ # domain: sessions, menus, users, permissions, messages, nodes, network adapters + oxidebbs-term/ # ANSI/CP437 rendering, AnsiBuffer, CP437 encode/decode + oxidebbs-telnet/ # telnet transport/negotiation plus serial/modem transport (serialport) + oxidebbs-db/ # DecentDB repository layer, OxideDb, schema init/migrations + oxidebbs-door/ # door definitions, drop files, DOS door runners, OxDoor packages + oxidebbs-sysop/ # local sysop admin ratatui TUI and CLI + oxidebbs-network/ # protocol-neutral network types (FTN addresses, profiles, links, envelopes) + oxidebbs-transfer/ # caller file transfer protocols (XMODEM-CRC, ZMODEM) + oxidebbs-ftn/ # FTN packets, bundles, tosser/scanner, areafix, nodelist, routing + oxidebbs-binkp/ # BinkP mail transport: framing, client/server sessions, TLS + oxidebbs-oxidenet/ # OxideNet profile, addressing defaults, node registry, config packages +design/ # ARCHITECTURE.md, SPEC.md, PRD.md, TASKS.md, ADRs +docs/ # VitePress documentation site (Node/npm) +config/ # oxidebbs.example.toml +scripts/ # dev-check.sh ``` -Only `oxidebbs-db` and `oxidebbs-term` have real implementation. Everything else is scaffolded stubs. +All 12 crates are implemented at v1.3.0; there are no remaining stubs. ## Dependency direction ``` -server -> core -> term, db, door, telnet -sysop -> core, db +server -> core, term, telnet, db, door, sysop, transfer, ftn, binkp, oxidenet + (all library crates except network, reached transitively via core) +core -> network +door -> core +sysop -> db, door, oxidenet +ftn -> network, db +oxidenet -> network, db +transfer -> telnet +term, telnet, db, network, binkp -> no internal deps ``` Lower-level crates must not depend on `oxidebbs-server`. @@ -78,7 +90,7 @@ Lower-level crates must not depend on `oxidebbs-server`. 1. Rust only, edition 2024. 2. DecentDB is the only database. No SQLite, Postgres, MySQL, Redis, MongoDB, or ORM. -3. Telnet-only for v1. No physical modem/serial yet. +3. v1 is telnet-first; serial/modem transport shipped in v1.2 per ADR 0019. 4. ANSI/CP437 is byte-oriented, not Unicode-first for the caller UI. 5. Do not use Ratatui for remote caller UI. Ratatui is permitted for local sysop TUI only. 6. Keep door execution isolated from core session logic. @@ -88,7 +100,7 @@ Lower-level crates must not depend on `oxidebbs-server`. All shared deps are declared in the root `[workspace.dependencies]`. Member crates reference them with `dep.workspace = true`. Use `cargo add` to add new deps; do not hand-edit versions. -Key deps: `anyhow`, `thiserror`, `serde`, `tokio` (full features), `tracing`, `clap` (derive+env), `decentdb` (git tag v2.8.0). +Key deps: `thiserror`, `serde`, `tokio` (full features), `tracing`, `clap` (derive+env), `decentdb` (git tag v2.8.0), `axum` (server web UI), `argon2`, `serialport`, `zip`, `time`, `ratatui`/`crossterm` (sysop TUI only). ## Rust code generation rules diff --git a/Cargo.lock b/Cargo.lock index 8c65a57..adad87d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -161,17 +161,6 @@ dependencies = [ "syn", ] -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -190,7 +179,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ - "axum-core 0.5.6", + "axum-core", "base64", "bytes", "form_urlencoded", @@ -220,26 +209,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum-core" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper", - "tower-layer", - "tower-service", -] - [[package]] name = "axum-core" version = "0.5.6" @@ -597,17 +566,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -876,7 +834,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", - "serde_core", ] [[package]] @@ -1078,20 +1035,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.32" @@ -1099,7 +1042,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -1108,12 +1050,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - [[package]] name = "futures-macro" version = "0.3.32" @@ -1603,7 +1539,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ "scopeguard", - "serde", ] [[package]] @@ -1883,7 +1818,7 @@ dependencies = [ [[package]] name = "oxidebbs-binkp" -version = "1.2.2" +version = "1.3.0" dependencies = [ "native-tls", "rcgen", @@ -1893,24 +1828,23 @@ dependencies = [ [[package]] name = "oxidebbs-core" -version = "1.2.2" +version = "1.3.0" dependencies = [ "oxidebbs-network", - "oxidebbs-transfer", "serde", "thiserror 2.0.18", ] [[package]] name = "oxidebbs-db" -version = "1.2.2" +version = "1.3.0" dependencies = [ "decentdb", ] [[package]] name = "oxidebbs-door" -version = "1.2.2" +version = "1.3.0" dependencies = [ "hex", "oxidebbs-core", @@ -1923,7 +1857,7 @@ dependencies = [ [[package]] name = "oxidebbs-ftn" -version = "1.2.2" +version = "1.3.0" dependencies = [ "oxidebbs-db", "oxidebbs-network", @@ -1936,7 +1870,7 @@ dependencies = [ [[package]] name = "oxidebbs-network" -version = "1.2.2" +version = "1.3.0" dependencies = [ "serde", "thiserror 2.0.18", @@ -1944,7 +1878,7 @@ dependencies = [ [[package]] name = "oxidebbs-oxidenet" -version = "1.2.2" +version = "1.3.0" dependencies = [ "hex", "oxidebbs-db", @@ -1956,7 +1890,7 @@ dependencies = [ [[package]] name = "oxidebbs-server" -version = "1.2.2" +version = "1.3.0" dependencies = [ "argon2", "axum", @@ -1985,8 +1919,6 @@ dependencies = [ "tokio-tungstenite 0.28.0", "toml", "tower", - "tower-cookies", - "tower-sessions", "tracing", "tracing-subscriber", "zip", @@ -1994,16 +1926,14 @@ dependencies = [ [[package]] name = "oxidebbs-sysop" -version = "1.2.2" +version = "1.3.0" dependencies = [ "argon2", "crossterm 0.28.1", "fuzzy-matcher", - "oxidebbs-core", "oxidebbs-db", "oxidebbs-door", "oxidebbs-oxidenet", - "oxidebbs-term", "rand_core 0.6.4", "ratatui", "serde", @@ -2016,7 +1946,7 @@ dependencies = [ [[package]] name = "oxidebbs-telnet" -version = "1.2.2" +version = "1.3.0" dependencies = [ "serialport", "thiserror 2.0.18", @@ -2026,11 +1956,11 @@ dependencies = [ [[package]] name = "oxidebbs-term" -version = "1.2.2" +version = "1.3.0" [[package]] name = "oxidebbs-transfer" -version = "1.2.2" +version = "1.3.0" dependencies = [ "oxidebbs-telnet", "thiserror 2.0.18", @@ -2272,37 +2202,16 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha 0.9.0", + "rand_chacha", "rand_core 0.9.5", ] -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - [[package]] name = "rand_chacha" version = "0.9.0" @@ -3129,23 +3038,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "tower-cookies" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fd0118512cf0b3768f7fcccf0bef1ae41d68f2b45edc1e77432b36c97c56c6d" -dependencies = [ - "async-trait", - "axum-core 0.4.5", - "cookie", - "futures-util", - "http", - "parking_lot", - "pin-project-lite", - "tower-layer", - "tower-service", -] - [[package]] name = "tower-layer" version = "0.3.3" @@ -3158,57 +3050,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" -[[package]] -name = "tower-sessions" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65856c81ee244e0f8a55ab0f7b769b72fbde387c235f0a73cd97c579818d05eb" -dependencies = [ - "async-trait", - "http", - "time", - "tokio", - "tower-cookies", - "tower-layer", - "tower-service", - "tower-sessions-core", - "tower-sessions-memory-store", - "tracing", -] - -[[package]] -name = "tower-sessions-core" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb6abbfcaf6436ec5a772cd9f965401da12db793e404ae6134eac066fa5a04f3" -dependencies = [ - "async-trait", - "axum-core 0.4.5", - "base64", - "futures", - "http", - "parking_lot", - "rand 0.8.6", - "serde", - "serde_json", - "thiserror 1.0.69", - "time", - "tokio", - "tracing", -] - -[[package]] -name = "tower-sessions-memory-store" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fad75660c8afbe74f4e7cbbe8e9090171a056b57370ea4d7d5e9eb3e4af3092" -dependencies = [ - "async-trait", - "time", - "tokio", - "tower-sessions-core", -] - [[package]] name = "tracing" version = "0.1.44" @@ -3295,7 +3136,7 @@ dependencies = [ "http", "httparse", "log", - "rand 0.9.4", + "rand", "sha1 0.10.6", "thiserror 2.0.18", "utf-8", @@ -3312,7 +3153,7 @@ dependencies = [ "http", "httparse", "log", - "rand 0.9.4", + "rand", "sha1 0.10.6", "thiserror 2.0.18", ] diff --git a/Cargo.toml b/Cargo.toml index 0979875..e67efe8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,15 +16,13 @@ members = [ ] [workspace.package] +version = "1.3.0" edition = "2024" license = "Apache-2.0" authors = ["Steven Hildreth"] repository = "https://github.com/sphildreth/oxidebbs" [workspace.dependencies] -# Keep these intentionally broad in the starter repo. -# Pin/update with `cargo add` once implementation begins. -anyhow = "1" argon2 = "0.5" rand_core = { version = "0.6", features = ["getrandom"] } thiserror = "2" @@ -44,6 +42,4 @@ sha2 = "0.11.0" hex = "0.4" zip = { version = "8.6.0", default-features = false, features = ["deflate"] } serialport = { version = "4", default-features = false } -tower-cookies = "0.10" -tower-sessions = "0.13" -time = "0.3" +time = { version = "0.3", features = ["formatting"] } diff --git a/VERSION b/VERSION index 23aa839..f0bb29e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.2 +1.3.0 diff --git a/compose.yaml b/compose.yaml index 4ca2118..a1e8f91 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,6 +1,6 @@ services: oxidebbs: - image: ghcr.io/sphildreth/oxidebbs:${OXIDEBBS_IMAGE_TAG:-1.2.2} + image: ghcr.io/sphildreth/oxidebbs:${OXIDEBBS_IMAGE_TAG:-1.3.0} init: true environment: OXIDEBBS_BOARD_NAME: ${OXIDEBBS_BOARD_NAME:-OxideBBS} diff --git a/crates/oxidebbs-binkp/Cargo.toml b/crates/oxidebbs-binkp/Cargo.toml index 260f11b..76128f0 100644 --- a/crates/oxidebbs-binkp/Cargo.toml +++ b/crates/oxidebbs-binkp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxidebbs-binkp" -version = "1.2.2" +version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true diff --git a/crates/oxidebbs-core/Cargo.toml b/crates/oxidebbs-core/Cargo.toml index 155f592..60991bd 100644 --- a/crates/oxidebbs-core/Cargo.toml +++ b/crates/oxidebbs-core/Cargo.toml @@ -1,12 +1,11 @@ [package] name = "oxidebbs-core" -version = "1.2.2" +version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true [dependencies] oxidebbs-network = { path = "../oxidebbs-network" } -oxidebbs-transfer = { path = "../oxidebbs-transfer" } serde.workspace = true thiserror.workspace = true diff --git a/crates/oxidebbs-db/Cargo.toml b/crates/oxidebbs-db/Cargo.toml index 12cbdd4..e61eb56 100644 --- a/crates/oxidebbs-db/Cargo.toml +++ b/crates/oxidebbs-db/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxidebbs-db" -version = "1.2.2" +version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true diff --git a/crates/oxidebbs-db/src/db_writer.rs b/crates/oxidebbs-db/src/db_writer.rs index 7d2a571..e48ab4f 100644 --- a/crates/oxidebbs-db/src/db_writer.rs +++ b/crates/oxidebbs-db/src/db_writer.rs @@ -34,7 +34,6 @@ impl DbWriter { } pub fn with_capacity(db: Db, capacity: usize) -> Self { - let db = db.clone(); let (command_tx, command_rx) = mpsc::sync_channel::(capacity); let worker = thread::spawn(move || { process_writes(db, command_rx); @@ -67,8 +66,7 @@ impl DbWriter { } pub fn shutdown(mut self) -> DbWriterResult<()> { - if let Err(error) = self.command_tx.send(QueuedWork::Shutdown) { - let _ = error; + if self.command_tx.send(QueuedWork::Shutdown).is_err() { return Err(DbWriterError::Shutdown); } diff --git a/crates/oxidebbs-db/src/migrations.rs b/crates/oxidebbs-db/src/migrations.rs index 3fdf2b6..e152fbb 100644 --- a/crates/oxidebbs-db/src/migrations.rs +++ b/crates/oxidebbs-db/src/migrations.rs @@ -98,10 +98,13 @@ fn migrate_4_to_5(db: &Db) -> decentdb::Result<()> { fn migrate_5_to_6(db: &Db) -> decentdb::Result<()> { match existing_schema_version(db)? { Some(5) => { - if doors_needs_security_level_rebuild(db)? { - rebuild_doors_for_security_level(db, 5)?; - } - set_schema_version(db, 6) + run_migration_transaction(db, || { + if doors_needs_security_level_rebuild(db)? { + rebuild_doors_for_security_level(db, 5)?; + } + set_schema_version(db, 6) + })?; + Ok(()) } Some(other) => Err(DbError::sql(format!( "Cannot apply migration 5 -> 6 from schema version {other}" @@ -115,20 +118,23 @@ fn migrate_5_to_6(db: &Db) -> decentdb::Result<()> { fn migrate_6_to_7(db: &Db) -> decentdb::Result<()> { match existing_schema_version(db)? { Some(6) => { - db.execute_batch( - "CREATE TABLE IF NOT EXISTS door_provider_credentials ( - id UUID PRIMARY KEY DEFAULT GEN_RANDOM_UUID(), - door_id UUID NOT NULL REFERENCES doors(id) ON DELETE CASCADE, - provider_name TEXT NOT NULL CHECK (LENGTH(TRIM(provider_name)) > 0), - credential_ref TEXT NOT NULL CHECK (LENGTH(TRIM(credential_ref)) > 0), - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE (door_id, provider_name) - ); - - CREATE INDEX IF NOT EXISTS idx_door_provider_credentials_door_id ON door_provider_credentials (door_id);", - )?; - set_schema_version(db, 7) + run_migration_transaction(db, || { + db.execute_batch( + "CREATE TABLE IF NOT EXISTS door_provider_credentials ( + id UUID PRIMARY KEY DEFAULT GEN_RANDOM_UUID(), + door_id UUID NOT NULL REFERENCES doors(id) ON DELETE CASCADE, + provider_name TEXT NOT NULL CHECK (LENGTH(TRIM(provider_name)) > 0), + credential_ref TEXT NOT NULL CHECK (LENGTH(TRIM(credential_ref)) > 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (door_id, provider_name) + ); + + CREATE INDEX IF NOT EXISTS idx_door_provider_credentials_door_id ON door_provider_credentials (door_id);", + )?; + set_schema_version(db, 7) + })?; + Ok(()) } Some(other) => Err(DbError::sql(format!( "Cannot apply migration 6 -> 7 from schema version {other}" diff --git a/crates/oxidebbs-db/src/network_repo.rs b/crates/oxidebbs-db/src/network_repo.rs index bd2ce9c..d413532 100644 --- a/crates/oxidebbs-db/src/network_repo.rs +++ b/crates/oxidebbs-db/src/network_repo.rs @@ -23,6 +23,11 @@ pub struct NetworkLinkRecord { pub address: String, pub host: String, pub binkp_port: i64, + /// BinkP session password, stored in cleartext by design: BinkP password + /// authentication (the M_PWD exchange, and CRAM-MD5-style challenge-response + /// variants) is a shared-secret protocol that requires the cleartext password + /// on both peers, so it cannot be stored as a one-way hash. Protect the + /// DecentDB file at the filesystem level (permissions, full-disk encryption). pub password: String, pub poll_schedule_minutes: i64, pub compression: String, @@ -380,11 +385,26 @@ pub fn insert_network_path_node(db: &Db, node: &NetworkPathNode) -> decentdb::Re pub fn insert_network_path(db: &Db, path: &[NetworkPathNode]) -> decentdb::Result<()> { db.begin_transaction()?; - for node in path { - insert_network_path_node(db, node)?; + let result = (|| { + for node in path { + insert_network_path_node(db, node)?; + } + Ok(()) + })(); + + match result { + Ok(()) => match db.commit_transaction() { + Ok(_) => Ok(()), + Err(error) => { + let _ = db.rollback_transaction(); + Err(error) + } + }, + Err(error) => { + let _ = db.rollback_transaction(); + Err(error) + } } - db.commit_transaction()?; - Ok(()) } pub fn insert_network_duplicate_log( @@ -414,7 +434,6 @@ pub fn insert_network_duplicate_log( Ok(()) } -#[allow(dead_code)] pub fn insert_network_poll_log(db: &Db, log: &NetworkPollLogRecord) -> decentdb::Result<()> { db.execute_with_params( "INSERT INTO network_poll_log (id, link_id, started_at, ended_at, direction, status, bytes_in, bytes_out, packets_in, packets_out, error_message) @@ -1998,6 +2017,33 @@ mod tests { assert_eq!(paths[1].sequence, 1); } + #[test] + fn insert_network_path_rolls_back_on_mid_insert_failure() { + let db = test_db(); + let profile = profile(); + insert_network_profile(&db, &profile).expect("insert profile"); + insert_message_area(&db); + insert_network_message(&db, &message(&profile.id)).expect("insert message"); + + // The second node references a nonexistent message, forcing a + // foreign-key failure after the first node has already been inserted. + let mut dangling = path_node(MESSAGE_ID, &profile.id, 1); + dangling.message_id = "00000000-0000-4000-8000-999999999999".to_string(); + let nodes = vec![path_node(MESSAGE_ID, &profile.id, 0), dangling]; + + insert_network_path(&db, &nodes).expect_err("dangling message_id must fail"); + + // The transaction rolled back: no partial rows survive... + let paths = list_network_path(&db).expect("list path after rollback"); + assert!(paths.is_empty()); + + // ...and the shared connection is usable for a fresh transaction. + insert_network_path(&db, &[path_node(MESSAGE_ID, &profile.id, 0)]) + .expect("insert after rollback"); + let paths = list_network_path(&db).expect("list path after retry"); + assert_eq!(paths.len(), 1); + } + #[test] fn duplicate_and_poll_logs_are_queryable() { let db = test_db(); @@ -2225,7 +2271,10 @@ mod tests { assert_eq!(stats.packets_scanned, 1); } + /// Inserts 50,000 nodelist rows; too slow for the default test run. + /// Run explicitly with `cargo test -p oxidebbs-db -- --ignored`. #[test] + #[ignore] fn stress_test_50000_entry_nodelist() { let db = test_db(); let profile = profile(); diff --git a/crates/oxidebbs-db/src/oxidenet_repo.rs b/crates/oxidebbs-db/src/oxidenet_repo.rs index 36a6430..c919450 100644 --- a/crates/oxidebbs-db/src/oxidenet_repo.rs +++ b/crates/oxidebbs-db/src/oxidenet_repo.rs @@ -555,7 +555,7 @@ mod tests { telnet_host: Some("bbs.example.test".to_string()), telnet_port: Some(23), software: "OxideBBS".to_string(), - software_version: "1.2.2".to_string(), + software_version: "1.3.0".to_string(), timezone: "America/Chicago".to_string(), region: "NA".to_string(), description: "test board".to_string(), @@ -587,7 +587,7 @@ mod tests { telnet_host: None, telnet_port: None, software: "OxideBBS".to_string(), - software_version: "1.2.2".to_string(), + software_version: "1.3.0".to_string(), status: "first-poll-pending".to_string(), created_at: CREATED_AT.to_string(), updated_at: CREATED_AT.to_string(), diff --git a/crates/oxidebbs-door/Cargo.toml b/crates/oxidebbs-door/Cargo.toml index e4b81ca..e425d4b 100644 --- a/crates/oxidebbs-door/Cargo.toml +++ b/crates/oxidebbs-door/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxidebbs-door" -version = "1.2.2" +version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true diff --git a/crates/oxidebbs-ftn/Cargo.toml b/crates/oxidebbs-ftn/Cargo.toml index 5a20a06..a4c4b59 100644 --- a/crates/oxidebbs-ftn/Cargo.toml +++ b/crates/oxidebbs-ftn/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxidebbs-ftn" -version = "1.2.2" +version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true diff --git a/crates/oxidebbs-network/Cargo.toml b/crates/oxidebbs-network/Cargo.toml index 3935a04..00ca32f 100644 --- a/crates/oxidebbs-network/Cargo.toml +++ b/crates/oxidebbs-network/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxidebbs-network" -version = "1.2.2" +version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true diff --git a/crates/oxidebbs-oxidenet/Cargo.toml b/crates/oxidebbs-oxidenet/Cargo.toml index f2a8425..0e8dd88 100644 --- a/crates/oxidebbs-oxidenet/Cargo.toml +++ b/crates/oxidebbs-oxidenet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxidebbs-oxidenet" -version = "1.2.2" +version.workspace = true edition.workspace = true license.workspace = true diff --git a/crates/oxidebbs-oxidenet/src/lib.rs b/crates/oxidebbs-oxidenet/src/lib.rs index 89285e2..f105074 100644 --- a/crates/oxidebbs-oxidenet/src/lib.rs +++ b/crates/oxidebbs-oxidenet/src/lib.rs @@ -1441,7 +1441,7 @@ mod tests { telnet_host: Some("retro.example.test".to_string()), telnet_port: Some(23), software: "OxideBBS".to_string(), - software_version: "1.2.2".to_string(), + software_version: "1.3.0".to_string(), timezone: "America/Chicago".to_string(), region: "NA".to_string(), description: "A retro ANSI board focused on doors and echomail.".to_string(), diff --git a/crates/oxidebbs-server/Cargo.toml b/crates/oxidebbs-server/Cargo.toml index 1e0231f..4c51af6 100644 --- a/crates/oxidebbs-server/Cargo.toml +++ b/crates/oxidebbs-server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxidebbs-server" -version = "1.2.2" +version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true @@ -28,8 +28,6 @@ thiserror.workspace = true time.workspace = true tokio.workspace = true toml.workspace = true -tower-cookies.workspace = true -tower-sessions.workspace = true tracing.workspace = true tracing-subscriber.workspace = true zip.workspace = true diff --git a/crates/oxidebbs-server/src/commands/db.rs b/crates/oxidebbs-server/src/commands/db.rs index 6c0b798..eae3300 100644 --- a/crates/oxidebbs-server/src/commands/db.rs +++ b/crates/oxidebbs-server/src/commands/db.rs @@ -2788,7 +2788,7 @@ mod tests { telnet_host: Some("bbs.example.test".to_string()), telnet_port: Some(23), software: "OxideBBS".to_string(), - software_version: "1.2.2".to_string(), + software_version: "1.3.0".to_string(), timezone: "America/Chicago".to_string(), region: "NA".to_string(), description: "test board".to_string(), @@ -2820,7 +2820,7 @@ mod tests { telnet_host: None, telnet_port: None, software: "OxideBBS".to_string(), - software_version: "1.2.2".to_string(), + software_version: "1.3.0".to_string(), status: "active".to_string(), created_at: "2026-06-04T01:00:00.000000Z".to_string(), updated_at: "2026-06-04T01:00:00.000000Z".to_string(), diff --git a/crates/oxidebbs-sysop/Cargo.toml b/crates/oxidebbs-sysop/Cargo.toml index b5d5a03..8ffe06f 100644 --- a/crates/oxidebbs-sysop/Cargo.toml +++ b/crates/oxidebbs-sysop/Cargo.toml @@ -1,19 +1,17 @@ [package] name = "oxidebbs-sysop" -version = "1.2.2" +version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true [dependencies] -oxidebbs-core = { path = "../oxidebbs-core" } oxidebbs-db = { path = "../oxidebbs-db" } oxidebbs-door = { path = "../oxidebbs-door" } oxidebbs-oxidenet = { path = "../oxidebbs-oxidenet" } -oxidebbs-term = { path = "../oxidebbs-term" } ratatui = { version = "0.30.0", default-features = false, features = ["crossterm"] } crossterm.workspace = true -tokio = { version = "1", features = ["full"] } +tokio.workspace = true thiserror.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/oxidebbs-telnet/Cargo.toml b/crates/oxidebbs-telnet/Cargo.toml index d60c408..c604545 100644 --- a/crates/oxidebbs-telnet/Cargo.toml +++ b/crates/oxidebbs-telnet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxidebbs-telnet" -version = "1.2.2" +version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true diff --git a/crates/oxidebbs-term/Cargo.toml b/crates/oxidebbs-term/Cargo.toml index f2dd5db..476451d 100644 --- a/crates/oxidebbs-term/Cargo.toml +++ b/crates/oxidebbs-term/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxidebbs-term" -version = "1.2.2" +version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true diff --git a/crates/oxidebbs-term/src/ansi_parser.rs b/crates/oxidebbs-term/src/ansi_parser.rs index 858c1c5..43064a7 100644 --- a/crates/oxidebbs-term/src/ansi_parser.rs +++ b/crates/oxidebbs-term/src/ansi_parser.rs @@ -1,8 +1,22 @@ +/// Maximum number of parameters collected for a single CSI sequence before +/// the sequence is aborted. +const MAX_CSI_PARAMS: usize = 32; + +/// Maximum number of intermediate bytes collected for a single sequence before +/// the sequence is aborted. +const MAX_INTERMEDIATES: usize = 8; + +/// Maximum OSC payload size in bytes before the sequence is aborted. +const MAX_OSC_PAYLOAD: usize = 4096; + #[derive(Debug, Clone, Eq, PartialEq)] pub enum AnsiSequence { Csi { params: Vec, intermediates: Vec, + /// ECMA-48 private parameter marker (`<`, `=`, `>`, or `?`), when the + /// sequence starts with one (e.g. `ESC[?25h` cursor visibility). + private_marker: Option, final_byte: u8, }, Osc { @@ -32,6 +46,7 @@ pub struct AnsiParser { params: Vec, current_param: Option, intermediates: Vec, + private_marker: Option, osc_payload: Vec, } @@ -42,6 +57,7 @@ impl AnsiParser { params: Vec::new(), current_param: None, intermediates: Vec::new(), + private_marker: None, osc_payload: Vec::new(), } } @@ -75,6 +91,7 @@ impl AnsiParser { self.params.clear(); self.current_param = None; self.intermediates.clear(); + self.private_marker = None; None } b']' => { @@ -83,7 +100,12 @@ impl AnsiParser { None } 0x20..=0x2f => { - self.intermediates.push(byte); + if self.intermediates.len() >= MAX_INTERMEDIATES { + self.state = ParseState::Ground; + self.intermediates.clear(); + } else { + self.intermediates.push(byte); + } None } 0x30..=0x7e => { @@ -101,37 +123,68 @@ impl AnsiParser { match byte { b'0'..=b'9' => { let digit = i64::from(byte - b'0'); - self.current_param = Some(self.current_param.unwrap_or(0) * 10 + digit); + self.current_param = Some( + self.current_param + .unwrap_or(0) + .saturating_mul(10) + .saturating_add(digit), + ); None } b';' => { + if self.params.len() >= MAX_CSI_PARAMS { + return self.abort_csi(); + } self.params.push(self.current_param.unwrap_or(0)); self.current_param = None; None } + 0x3c..=0x3f => { + // ECMA-48 private parameter bytes; only valid as the leading + // parameter byte before any numeric parameters. + if self.private_marker.is_none() + && self.params.is_empty() + && self.current_param.is_none() + { + self.private_marker = Some(byte); + None + } else { + self.abort_csi() + } + } 0x20..=0x2f => { + if self.intermediates.len() >= MAX_INTERMEDIATES { + return self.abort_csi(); + } self.intermediates.push(byte); None } 0x40..=0x7e => { + if self.params.len() >= MAX_CSI_PARAMS { + return self.abort_csi(); + } self.params.push(self.current_param.unwrap_or(0)); self.state = ParseState::Ground; Some(ParseEvent::Sequence(AnsiSequence::Csi { params: std::mem::take(&mut self.params), intermediates: std::mem::take(&mut self.intermediates), + private_marker: self.private_marker.take(), final_byte: byte, })) } - _ => { - self.state = ParseState::Ground; - self.params.clear(); - self.current_param = None; - self.intermediates.clear(); - None - } + _ => self.abort_csi(), } } + fn abort_csi(&mut self) -> Option { + self.state = ParseState::Ground; + self.params.clear(); + self.current_param = None; + self.intermediates.clear(); + self.private_marker = None; + None + } + fn feed_osc(&mut self, byte: u8) -> Option { match byte { 0x07 => { @@ -152,7 +205,14 @@ impl AnsiParser { })) } _ => { - self.osc_payload.push(byte); + if self.osc_payload.len() >= MAX_OSC_PAYLOAD { + // Unterminated or hostile OSC stream: abort instead of + // growing the payload buffer without bound. + self.state = ParseState::Ground; + self.osc_payload.clear(); + } else { + self.osc_payload.push(byte); + } None } } @@ -206,6 +266,7 @@ mod tests { vec![ParseEvent::Sequence(AnsiSequence::Csi { params: vec![12, 40], intermediates: vec![], + private_marker: None, final_byte: b'H', })] ); @@ -220,6 +281,7 @@ mod tests { vec![ParseEvent::Sequence(AnsiSequence::Csi { params: vec![31, 1], intermediates: vec![], + private_marker: None, final_byte: b'm', })] ); @@ -234,6 +296,7 @@ mod tests { vec![ParseEvent::Sequence(AnsiSequence::Csi { params: vec![0, 0], intermediates: vec![], + private_marker: None, final_byte: b'H', })] ); @@ -262,12 +325,14 @@ mod tests { ParseEvent::Sequence(AnsiSequence::Csi { params: vec![1], intermediates: vec![], + private_marker: None, final_byte: b'm', }), ParseEvent::Char(b'!'), ParseEvent::Sequence(AnsiSequence::Csi { params: vec![0], intermediates: vec![], + private_marker: None, final_byte: b'm', }), ] @@ -302,8 +367,65 @@ mod tests { vec![ParseEvent::Sequence(AnsiSequence::Csi { params: vec![0], intermediates: vec![], + private_marker: None, final_byte: b'c', })] ); } + + #[test] + fn parses_csi_private_marker() { + let mut parser = AnsiParser::new(); + let events = parser.feed_all(b"\x1b[?25h"); + assert_eq!( + events, + vec![ParseEvent::Sequence(AnsiSequence::Csi { + params: vec![25], + intermediates: vec![], + private_marker: Some(b'?'), + final_byte: b'h', + })] + ); + } + + #[test] + fn strip_ansi_removes_private_csi_without_leaking_bytes() { + assert_eq!(strip_ansi(b"\x1b[?25h"), b""); + assert_eq!(strip_ansi(b"\x1b[?25lHidden?\x1b[?25h"), b"Hidden?"); + } + + #[test] + fn saturates_overflowing_csi_param_instead_of_panicking() { + let mut parser = AnsiParser::new(); + let mut input = b"\x1b[".to_vec(); + input.extend_from_slice(&[b'9'; 25]); + input.push(b'H'); + let events = parser.feed_all(&input); + assert_eq!( + events, + vec![ParseEvent::Sequence(AnsiSequence::Csi { + params: vec![i64::MAX], + intermediates: vec![], + private_marker: None, + final_byte: b'H', + })] + ); + } + + #[test] + fn aborts_unterminated_osc_at_payload_cap() { + let mut parser = AnsiParser::new(); + let mut input = b"\x1b]0;".to_vec(); + input.extend_from_slice(&[b'x'; MAX_OSC_PAYLOAD + 16]); + let events = parser.feed_all(&input); + // The OSC is aborted at the cap: no Osc event, payload buffer + // cleared, and the parser is back in the ground state. + assert!( + !events + .iter() + .any(|event| matches!(event, ParseEvent::Sequence(_))) + ); + assert!(parser.osc_payload.is_empty()); + assert!(matches!(parser.state, ParseState::Ground)); + } } diff --git a/crates/oxidebbs-term/src/lib.rs b/crates/oxidebbs-term/src/lib.rs index 5226cba..92f9520 100644 --- a/crates/oxidebbs-term/src/lib.rs +++ b/crates/oxidebbs-term/src/lib.rs @@ -31,6 +31,45 @@ const CP437_HIGH: [char; 128] = [ '\u{00b0}', '\u{2219}', '\u{00b7}', '\u{221a}', '\u{207f}', '\u{00b2}', '\u{25a0}', '\u{00a0}', ]; +/// CP437/VGA glyphs for bytes `0x01..=0x1F` and `0x7F`. +/// +/// Bytes `0x09` (tab), `0x0A` (LF), `0x0D` (CR), and `0x1B` (ESC) are +/// deliberately absent from this table: they are structural controls in `.ans` +/// files and keep their ASCII control meaning in both directions. As a +/// consequence the glyphs that share those bytes on real CP437 hardware +/// (`○` U+25CB, `◙` U+25D9, `♪` U+266A, `←` U+2190) decode to the control +/// meaning and are not encodable. +const CP437_LOW: [(u8, char); 28] = [ + (0x01, '\u{263A}'), + (0x02, '\u{263B}'), + (0x03, '\u{2665}'), + (0x04, '\u{2666}'), + (0x05, '\u{2663}'), + (0x06, '\u{2660}'), + (0x07, '\u{2022}'), + (0x08, '\u{25D8}'), + (0x0B, '\u{2642}'), + (0x0C, '\u{2640}'), + (0x0E, '\u{266B}'), + (0x0F, '\u{263C}'), + (0x10, '\u{25BA}'), + (0x11, '\u{25C4}'), + (0x12, '\u{2195}'), + (0x13, '\u{203C}'), + (0x14, '\u{00B6}'), + (0x15, '\u{00A7}'), + (0x16, '\u{25AC}'), + (0x17, '\u{21A8}'), + (0x18, '\u{2191}'), + (0x19, '\u{2193}'), + (0x1A, '\u{2192}'), + (0x1C, '\u{221F}'), + (0x1D, '\u{2194}'), + (0x1E, '\u{25B2}'), + (0x1F, '\u{25BC}'), + (0x7F, '\u{2302}'), +]; + #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum TerminalProfile { Ansi80, @@ -488,19 +527,48 @@ pub fn encode_cp437(input: &str) -> Result, Cp437EncodeError> { Ok(bytes) } +/// Decode a CP437 byte to its Unicode character. +/// +/// Policy for the low range (`0x00..=0x7F`): +/// - `0x00` decodes to NUL (U+0000). +/// - The structural control bytes `0x09` (tab), `0x0A` (LF), `0x0D` (CR), and +/// `0x1B` (ESC) keep their ASCII control meaning: `.ans` files use them +/// structurally and downstream plain-text rendering relies on their line +/// handling. +/// - All other bytes in `0x01..=0x1F`, plus `0x7F`, decode to their CP437/VGA +/// glyphs (smileys, card suits, arrows, etc.) via [`CP437_LOW`]; printable +/// ASCII passes through unchanged. pub fn cp437_byte_to_char(byte: u8) -> char { - if byte < 0x80 { - char::from(byte) - } else { - CP437_HIGH[usize::from(byte - 0x80)] + match byte { + 0x00 | 0x09 | 0x0a | 0x0d | 0x1b | 0x20..=0x7e => char::from(byte), + 0x80..=0xff => CP437_HIGH[usize::from(byte - 0x80)], + _ => CP437_LOW + .iter() + .find(|(mapped_byte, _)| *mapped_byte == byte) + .map(|(_, character)| *character) + .unwrap_or('\u{FFFD}'), } } +/// Encode a Unicode character to its CP437 byte, if representable. +/// +/// The asymmetry with [`cp437_byte_to_char`] is intentional: bytes +/// `0x09`/`0x0A`/`0x0D`/`0x1B` are reserved for their structural control +/// meaning, so the glyphs that would share those bytes on real CP437 hardware +/// (`○` U+25CB, `◙` U+25D9, `♪` U+266A, `←` U+2190) are not encodable and +/// return `None`. pub fn char_to_cp437_byte(character: char) -> Option { if character.is_ascii() { return Some(character as u8); } + if let Some((byte, _)) = CP437_LOW + .iter() + .find(|(_, mapped_character)| *mapped_character == character) + { + return Some(*byte); + } + CP437_HIGH .iter() .position(|mapped| *mapped == character) @@ -653,6 +721,49 @@ mod tests { assert_eq!(encoded, [0xc8, 0xcd, 0xbc]); } + #[test] + fn decodes_cp437_low_range_glyphs() { + assert_eq!(cp437_byte_to_char(0x01), '\u{263A}'); + assert_eq!(cp437_byte_to_char(0x03), '\u{2665}'); + assert_eq!(cp437_byte_to_char(0x10), '\u{25BA}'); + assert_eq!(cp437_byte_to_char(0x7f), '\u{2302}'); + } + + #[test] + fn preserves_structural_control_bytes_in_cp437_decode() { + assert_eq!(cp437_byte_to_char(0x00), '\u{0000}'); + assert_eq!(cp437_byte_to_char(0x09), '\t'); + assert_eq!(cp437_byte_to_char(0x0a), '\n'); + assert_eq!(cp437_byte_to_char(0x0d), '\r'); + assert_eq!(cp437_byte_to_char(0x1b), '\u{001b}'); + } + + #[test] + fn round_trips_cp437_low_range_glyphs() { + for (character, byte) in [ + ('\u{263A}', 0x01), + ('\u{25BA}', 0x10), + ('\u{2302}', 0x7f), + ('\u{2665}', 0x03), + ] { + assert_eq!(char_to_cp437_byte(character), Some(byte)); + assert_eq!(cp437_byte_to_char(byte), character); + } + } + + #[test] + fn rejects_glyphs_colliding_with_preserved_control_bytes() { + // These glyphs share bytes 0x09/0x0A/0x0D/0x1B with preserved + // structural controls, so they intentionally cannot round-trip. + for glyph in ['\u{25CB}', '\u{25D9}', '\u{266A}', '\u{2190}'] { + assert_eq!(char_to_cp437_byte(glyph), None); + } + + let error = encode_cp437("\u{266A}").expect_err("eighth note collides with CR byte 0x0D"); + assert_eq!(error.character(), '\u{266A}'); + assert_eq!(error.byte_index(), 0); + } + #[test] fn reports_unrepresentable_cp437_character() { let error = encode_cp437("BBS \u{1f680}").expect_err("rocket is not CP437"); diff --git a/crates/oxidebbs-transfer/Cargo.toml b/crates/oxidebbs-transfer/Cargo.toml index d659ff4..253dc97 100644 --- a/crates/oxidebbs-transfer/Cargo.toml +++ b/crates/oxidebbs-transfer/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxidebbs-transfer" -version = "1.2.2" +version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true diff --git a/design/ARCHITECTURE.md b/design/ARCHITECTURE.md index 183f229..c83451a 100644 --- a/design/ARCHITECTURE.md +++ b/design/ARCHITECTURE.md @@ -90,6 +90,11 @@ Owns BinkP network-mail transport framing and client/server session primitives. Owns OxideNet-specific profile data, addressing defaults, applications, node registry, and config package structures. +### `oxidebbs-transfer` + +Owns caller file transfer protocols (XMODEM-CRC and ZMODEM) built on the +byte-oriented transport layer provided by `oxidebbs-telnet`. + ## Dependency rule Higher-level crates may depend on lower-level crates. Lower-level crates should not depend on the server binary. @@ -97,8 +102,13 @@ Higher-level crates may depend on lower-level crates. Lower-level crates should Preferred direction: ```text -server -> core -> term/db/door/telnet -sysop -> core/db +server -> core/term/telnet/db/door/sysop/transfer/ftn/binkp/oxidenet +core -> network +door -> core +sysop -> db/door/oxidenet +ftn -> network/db +oxidenet -> network/db +transfer -> telnet ``` ## Important design principle diff --git a/design/SPEC.md b/design/SPEC.md index dc79f28..f27da61 100644 --- a/design/SPEC.md +++ b/design/SPEC.md @@ -14,6 +14,10 @@ It should ship as one primary server binary with internal crates for domain boun - `oxidebbs-door` - `oxidebbs-sysop` - `oxidebbs-network` +- `oxidebbs-transfer` +- `oxidebbs-ftn` +- `oxidebbs-binkp` +- `oxidebbs-oxidenet` This keeps the system easy to run while keeping the codebase clean. @@ -139,6 +143,20 @@ The terminal layer should support: - Safe line editor for caller input - Output paging +CP437 decode policy for the low range (`0x01..=0x1F`, `0x7F`): bytes decode to +their CP437/VGA glyphs (smileys, card suits, arrows, `⌂`), because `.ans` art +uses those bytes as graphics. The structural control bytes `0x09` (tab), `0x0A` +(LF), `0x0D` (CR), and `0x1B` (ESC) keep their control meaning in both +directions, so the four glyphs that share those bytes on real hardware (`○`, +`◙`, `♪`, `←`) decode to the control meaning and are intentionally not +encodable. + +The ANSI parser accepts ECMA-48 private parameter bytes (`<`, `=`, `>`, `?`) +on CSI sequences (e.g. `ESC[?25h` cursor visibility) and strips them cleanly +in plain-text fallbacks. Parser accumulation is bounded (parameter count, +intermediate count, OSC payload size, and saturating numeric parameters) so +malformed or hostile input cannot panic or exhaust memory. + The named caller terminal profiles are: | Profile | Purpose | Width x height | Charset | ANSI/control policy | @@ -158,9 +176,10 @@ Menus and generated caller text should wrap or truncate at the active profile width instead of assuming 80 columns. ANSI/CP437 art must have an ASCII, 40-column, or C64-safe fallback path for basic navigation. -PETSCII translation is not complete yet. The terminal abstraction must keep the -charset field explicit and route C64 callers through ASCII/PETSCII-friendly -fallback assets until full PETSCII encode/decode support is implemented. +PETSCII encode/decode support is implemented (ADR 0034), including a lossy +replacement policy for characters with no PETSCII representation. The terminal +abstraction keeps the charset field explicit and routes C64 callers through +PETSCII with ASCII-friendly fallback assets. Plain and C64 profiles must avoid advanced ANSI escape sequences for screen clear, cursor movement, color, and box drawing unless a sysop deliberately diff --git a/design/VERSIONING_GUIDE.md b/design/VERSIONING_GUIDE.md index 4bf70d7..bb2779d 100644 --- a/design/VERSIONING_GUIDE.md +++ b/design/VERSIONING_GUIDE.md @@ -58,13 +58,8 @@ placeholder. ### Rust workspace -- `crates/oxidebbs-server/Cargo.toml` -- `crates/oxidebbs-core/Cargo.toml` -- `crates/oxidebbs-term/Cargo.toml` -- `crates/oxidebbs-telnet/Cargo.toml` -- `crates/oxidebbs-db/Cargo.toml` -- `crates/oxidebbs-door/Cargo.toml` -- `crates/oxidebbs-sysop/Cargo.toml` +- `Cargo.toml` (`[workspace.package] version`; all member crates use + `version.workspace = true`) - `Cargo.lock` - `VERSION` - `scripts/bump-version.sh` diff --git a/docs/OXDOOR_FORMAT_V1.md b/docs/OXDOOR_FORMAT_V1.md index c3b929f..0955339 100644 --- a/docs/OXDOOR_FORMAT_V1.md +++ b/docs/OXDOOR_FORMAT_V1.md @@ -1,7 +1,7 @@ # Oxide Door Package Format v1 (`.oxdoor`) See the canonical specification in -[`design/OXDOOR_FORMAT_V1.md`](../design/OXDOOR_FORMAT_V1.md). +[`design/OXDOOR_FORMAT_V1.md`](https://github.com/sphildreth/oxidebbs/blob/main/design/OXDOOR_FORMAT_V1.md). In short: diff --git a/docs/about/changelog.md b/docs/about/changelog.md index 2f1bdc7..dcaa016 100644 --- a/docs/about/changelog.md +++ b/docs/about/changelog.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.0] - 2026-08-05 + ### Added - `oxidebbs-core::constants` to centralise default configuration values such as the default door time limit and the default BinkP port. @@ -17,6 +19,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 lossy-replacement policy for unsupported glyphs. See ADR 0034. - `TerminalCharset::Petscii` (config string `"petscii"`) and config support for selecting it; `petscii_ascii_fallback` remains supported for operators. +- CP437 low-range glyph decode/encode in `oxidebbs-term`: bytes `0x01..=0x1F` + and `0x7F` now map to their CP437/VGA glyphs (smileys, card suits, arrows, + `⌂`) so `.ans` art using those bytes renders correctly. Structural control + bytes (tab, LF, CR, ESC) keep their control meaning. +- ECMA-48 private parameter byte support (`<`, `=`, `>`, `?`) on CSI sequences + in the ANSI parser, so sequences such as `ESC[?25h` (cursor visibility) are + understood and stripped cleanly by plain-text fallbacks. ### Changed - The built-in C64 terminal profile and default/example C64 config now select @@ -25,6 +34,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 C64 callers receive PETSCII-encoded menus, messages, file lists, and logoff screens. ANSI/CP437 and plain-ASCII behavior is unchanged, and binary file-transfer and telnet negotiation bytes are never re-encoded. +- Workspace dependency hygiene: crate versions are centralized under + `[workspace.package]`, and unused workspace/member dependencies (`anyhow`, + `tower-cookies`, `tower-sessions`, and several unused internal path deps) + were removed. +- Project documentation corrections: `AGENTS.md`, `design/ARCHITECTURE.md`, + `design/SPEC.md`, and the Rust code-generation skill now match the actual + 12-crate workspace, dependency graph, and serial-transport status. + +### Fixed +- ANSI parser numeric parameters now saturate instead of panicking on + overflowing values, and parser accumulation is bounded (CSI parameter count, + intermediate count, OSC payload size) so malformed or hostile input cannot + exhaust memory. +- `insert_network_path` in `oxidebbs-db` now rolls back on mid-insert failure, + leaving no partial rows and a usable connection. +- Schema migrations 5→6 and 6→7 are now transactional like the other + migrations, so a failed migration cannot leave the database half-migrated. +- The 50,000-entry nodelist stress test is marked `#[ignore]` so the default + test run stays fast. ## [1.2.2] - 2026-06-07 diff --git a/docs/project/docker.md b/docs/project/docker.md index ee03bae..0a039c2 100644 --- a/docs/project/docker.md +++ b/docs/project/docker.md @@ -41,14 +41,14 @@ OXIDEBBS_SYSOP_PASSWORD='choose-a-real-password' docker compose up -d The default Compose file uses: ```text -ghcr.io/sphildreth/oxidebbs:1.2.2 +ghcr.io/sphildreth/oxidebbs:1.3.0 ``` To pin or test another published image tag: ```bash -OXIDEBBS_IMAGE_TAG=1.2.2 docker compose pull -OXIDEBBS_IMAGE_TAG=1.2.2 \ +OXIDEBBS_IMAGE_TAG=1.3.0 docker compose pull +OXIDEBBS_IMAGE_TAG=1.3.0 \ OXIDEBBS_SYSOP_PASSWORD='choose-a-real-password' \ docker compose up -d ``` @@ -192,15 +192,15 @@ image has built and passed the `oxidebbs-server --version` smoke test. Preferred stable tags: ```text -ghcr.io/sphildreth/oxidebbs:1.2.2 -ghcr.io/sphildreth/oxidebbs:v1.2.2 +ghcr.io/sphildreth/oxidebbs:1.3.0 +ghcr.io/sphildreth/oxidebbs:v1.3.0 ``` Pull a published image directly: ```bash -docker pull ghcr.io/sphildreth/oxidebbs:1.2.2 -docker run --rm --entrypoint oxidebbs-server ghcr.io/sphildreth/oxidebbs:1.2.2 --version +docker pull ghcr.io/sphildreth/oxidebbs:1.3.0 +docker run --rm --entrypoint oxidebbs-server ghcr.io/sphildreth/oxidebbs:1.3.0 --version ``` Stable release publishes also move: diff --git a/package-lock.json b/package-lock.json index d20729a..3e89254 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oxidebbs-docs", - "version": "1.2.2", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oxidebbs-docs", - "version": "1.2.2", + "version": "1.3.0", "devDependencies": { "vitepress": "^2.0.0-alpha.17" } diff --git a/package.json b/package.json index f9b6b00..92ab8a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oxidebbs-docs", - "version": "1.2.2", + "version": "1.3.0", "private": true, "type": "module", "scripts": { diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index 01ce5c1..4ace835 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -14,6 +14,11 @@ fi printf '%s\n' "$VERSION_VALUE" > VERSION +# Crate versions are centralized in [workspace.package]; member manifests use +# version.workspace = true. The crate loop below is kept for any manifest that +# still pins a literal version. +perl -0pi -e 's/^(\[workspace\.package\][^\[]*?^version = )"[^"]+"/$1"'"$VERSION_VALUE"'"/m' Cargo.toml + for manifest in crates/*/Cargo.toml; do perl -0pi -e "s/^version = \"[^\"]+\"/version = \"$VERSION_VALUE\"/m" "$manifest" done From 894d6cc1c74336cf10fafac4ff91b8de8e38d7b0 Mon Sep 17 00:00:00 2001 From: Steven Hildreth Date: Wed, 5 Aug 2026 17:19:49 -0500 Subject: [PATCH 8/9] docs: update AGENTS.md to clarify crate implementation status and versioning --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index b222376..0086749 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,8 @@ config/ # oxidebbs.example.toml scripts/ # dev-check.sh ``` -All 12 crates are implemented at v1.3.0; there are no remaining stubs. +All 12 crates are fully implemented; there are no remaining stubs. The current +release version lives in the root `VERSION` file — see `design/VERSIONING_GUIDE.md`. ## Dependency direction From ca4e20e74f1a29bbaa7f9f1b42bf5fe1ba97205e Mon Sep 17 00:00:00 2001 From: Steven Hildreth Date: Wed, 5 Aug 2026 17:25:10 -0500 Subject: [PATCH 9/9] Update release planning documents for v1.4.0 - Transitioned active release work from v1.3.0 to v1.4.0 in TASKS.md. - Added new release plan document for v1.4.0, detailing scope, phases, and implementation tasks. - Documented completion of C64/PETSCII terminal features in v1.3.0 and outlined pending work for v1.4.0. - Established phase status map and candidate coverage matrix for v1.4.0. - Included ADRs relevant to v1.4.0 and clarified known scope tensions. --- design/PRD.md | 4 +- design/RELEASE_v1_3_PLAN.md | 515 +++--------------------------------- design/RELEASE_v1_4_PLAN.md | 505 +++++++++++++++++++++++++++++++++++ design/TASKS.md | 9 +- 4 files changed, 553 insertions(+), 480 deletions(-) create mode 100644 design/RELEASE_v1_4_PLAN.md diff --git a/design/PRD.md b/design/PRD.md index 9e95f1c..d8c515b 100644 --- a/design/PRD.md +++ b/design/PRD.md @@ -188,8 +188,8 @@ shipped in v1.2 per - Web-based read-only status dashboard - Caller file-area transfers: ZMODEM primary and XMODEM-CRC fallback -Post-v1.2 candidates are tracked in -[`design/RELEASE_v1_3_PLAN.md`](./RELEASE_v1_3_PLAN.md). Door work after v1.2 +Post-v1.3 candidates are tracked in +[`design/RELEASE_v1_4_PLAN.md`](./RELEASE_v1_4_PLAN.md). Door work after v1.2 focuses on compatibility with existing door games, drop-file formats, provider behavior, and sysop tooling. diff --git a/design/RELEASE_v1_3_PLAN.md b/design/RELEASE_v1_3_PLAN.md index 8794bd2..b0af5ad 100644 --- a/design/RELEASE_v1_3_PLAN.md +++ b/design/RELEASE_v1_3_PLAN.md @@ -1,481 +1,44 @@ # OxideBBS v1.3 Release Plan -Document status: Planning draft +Document status: Closed — released as `v1.3.0` on 2026-08-05 Created: 2026-06-05 -Release intent: `v1.3.0` is the post-v1.2 compatibility release. It collects -the remaining items that current documentation still marks as post-v1.2, future, -outside v1.2 scope, or unresolved compatibility work after the v1.2 -deferred-scope release. - -This plan does not reopen `v1.2.0` completion. Features marked complete in -[`design/RELEASE_v1_2_PLAN.md`](./RELEASE_v1_2_PLAN.md) remain the v1.2 -baseline. v1.3 work must be scoped by new ADRs where existing ADRs deliberately -kept behavior outside v1.2. - -## Phase Status Map - -Status values: - -- `Complete`: the planning artifact exists and no code work remains for that - planning phase. -- `Partial`: some foundation, scaffolding, documentation, or narrow behavior - exists, but the phase exit gate is not fully satisfied. -- `Planned`: ready for implementation, no coding started. -- `Active`: implementation is underway. -- `Blocked`: implementation cannot proceed until the named dependency changes. - -| Phase | Title | Status | Exit Gate | -| --- | --- | --- | --- | -| P0 | Scope freeze and ADR baseline | Planned | This plan is accepted and ADRs exist for door compatibility, PETSCII/manual profile persistence, transfer protocol expansion, and FTN/OxideNet compatibility boundaries. | -| P1 | Documentation reconciliation and task tracker update | Planned | v1.2/v1.3 docs no longer contradict each other about door compatibility, Wildcat variants, OxideNet converter work, or transfer protocol scope. | -| P2 | C64/PETSCII terminal completion | Planned | Full PETSCII encode/decode support, profile-aware rendering tests, and caller-flow acceptance tests exist beyond the current ASCII/PETSCII-friendly fallback. | -| P3 | Manual terminal profile persistence | Planned | User/account schema stores terminal preference, onboarding/manual selection can persist it, and telnet detection/default-profile fallback order is documented and tested. | -| P4 | Caller transfer protocol decision and expansion | Blocked | A maintainer-approved ADR supersedes or preserves ADR 0031 for YMODEM, XMODEM-1k, checksum XMODEM, and related caller-transfer variants. | -| P5 | Door drop-file compatibility expansion | Planned | Current drop-file docs and renderers agree; additional Wildcat/vendor-specific variants are either implemented with byte-exact tests or explicitly deferred by ADR. | -| P6 | FTN/OxideNet interoperability hardening | Blocked | Real-network/operator feedback or a maintainer-approved compatibility ADR defines Seen-by/PATH tuning, archive-format expansion, mailer scheduling/status gaps, and non-OxideBBS bridge expectations. | -| P7 | OxideNet topology and public-network expansion | Planned | Backup hub, future net ranges, policy-authority workflow, public listing, and reachability validation are either implemented or explicitly kept as reserved capacity. | -| P8 | Final integration and release readiness | Planned | Rust gate, docs build, stale wording scan, release notes, version metadata, and local package smoke checks pass for v1.3. | - -## Reviewed Documentation - -This plan is based on a review of: - -- `README.md` -- `docs/**/*.md` -- `design/RELEASE_v1_1_PLAN.md` -- `design/RELEASE_v1_2_PLAN.md` -- `design/TASKS.md` -- `design/PRD.md` -- `design/ROADMAP.md` -- `design/SPEC.md` -- `design/TELNET.md` -- `design/ANSI_CP437.md` -- `design/DOORS.md` -- `design/DOOR_GAME_RESOURCES.md` -- `design/FILE_TRANSFERS.md` -- `design/MAILER.md` -- `design/FTN_PLAN.md` -- `design/OXIDENET_PRD.md` -- `design/adr/*.md` - -Third-party package documentation under `node_modules/` was intentionally not -treated as OxideBBS product scope. - -## ADRs For v1.3 - -| ADR | Topic | Used By | -| --- | --- | --- | -| ADR 0033 | Door compatibility scope | P0, P1, P5 | -| ADR 0034 | PETSCII translation and terminal-profile persistence policy | P2, P3 | -| ADR 0035 | Caller file-transfer protocol expansion decision | P4 | -| ADR 0036 | FTN/OxideNet interoperability and bridge policy | P6, P7 | - -ADR 0033 is accepted. ADR 0034 through ADR 0036 are proposed placeholders. If -other ADRs are created first, renumber the placeholder rows before -implementation starts. - -## v1.3 Candidate Coverage Matrix - -Every row below must either be implemented, tested, and documented before v1.3 -is declared complete, or explicitly moved out of v1.3 by a maintainer-approved -ADR. - -| Candidate Item | Source Documents | v1.3 Phase | -| --- | --- | --- | -| Full PETSCII encode/decode rendering beyond ASCII fallback | `TASKS.md`, `SPEC.md`, `TELNET.md`, `ANSI_CP437.md`, `PRD.md` | P2 | -| Persist manual terminal profile selection in user/account settings | `TASKS.md`, `SPEC.md`, `TELNET.md` | P3 | -| YMODEM and XMODEM-1k reconsideration | `FILE_TRANSFERS.md`, ADR 0031, `docs/project/file-transfers.md` | P4 | -| Checksum XMODEM, XMODEM-g, ZedZap/ZMODEM-8K, Kermit, and other transfer variants | `FILE_TRANSFERS.md`, ADR 0031 | P4 | -| Additional Wildcat and vendor-specific drop-file variants | `DOORS.md`, `DOOR_GAME_RESOURCES.md`, `RELEASE_v1_1_PLAN.md`, `RELEASE_v1_2_PLAN.md` | P5 | -| Seen-by and PATH interoperability tuning after real-network feedback | `FTN_PLAN.md`, `OXIDENET_PRD.md`, `RELEASE_v1_1_PLAN.md` | P6 | -| Additional outbound bundle archive formats beyond ZIP | `FTN_PLAN.md`, ADR 0028 | P6 | -| Scheduled polling, external-mailer directory drop docs, and mailer CLI status/queue checklist reconciliation | `MAILER.md`, `RELEASE_v1_2_PLAN.md` | P1, P6 | -| Non-OxideBBS OxideNet participation and FTN-to-internal converter reconciliation | `FTN_PLAN.md`, `OXIDENET_PRD.md`, `RELEASE_v1_2_PLAN.md` | P1, P6 | -| OxideNet backup hub, future net ranges, and public-network topology expansion | `OXIDENET_PRD.md`, `docs/oxidenet/addressing.md`, `RELEASE_v1_2_PLAN.md` | P7 | - -## Known Scope Tensions - -The v1.3 scope starts with several documentation tensions that must be resolved -before implementation agents treat the plan as executable: - -- ADR 0033 defines the door compatibility boundary. v1.3 door work is limited - to compatibility with existing door games, drop-file formats, provider - behavior, and sysop tooling. -- `design/RELEASE_v1_2_PLAN.md` says P5 completed Wildcat/PCBoard drop-file - coverage, while `design/DOORS.md` says additional Wildcat and vendor-specific - variants remain future compatibility work. v1.3 must identify exact remaining - variants or move them out of active scope. -- `design/RELEASE_v1_2_PLAN.md` says P15 covered non-OxideBBS participation - boundaries and an FTN-to-internal converter, while `design/FTN_PLAN.md` and - `design/OXIDENET_PRD.md` still describe those as post-v1.2 or later - compatibility. v1.3 must reconcile whether v1.2 delivered documentation - boundaries only, or whether concrete bridge implementation is still intended. -- `design/MAILER.md` still has unchecked implementation checklist rows for - scheduled polling, external-mailer directory drop documentation, and CLI - status/queue views, while the v1.2 release plan marks BinkP and FTN - operations complete. v1.3 must decide whether those rows are stale checklist - text, v1.3 compatibility work, or intentionally out of scope. -- ADR 0031 explicitly keeps YMODEM, XMODEM-1k, checksum XMODEM, ZedZap, and - similar caller-transfer variants outside v1.2 unless a later ADR supersedes - it. v1.3 cannot implement or advertise those protocols until P4 accepts a new - transfer policy. - -## Global Implementation Rules - -These rules apply to every phase: - -1. Use Rust edition 2024. -2. Keep DecentDB as the only database. -3. Use `cargo add` for new dependencies; do not hand-edit versions. -4. Keep shared dependency versions in root `[workspace.dependencies]`. -5. No `unwrap()` or `expect()` in library code. -6. Never hold a lock across `.await`. -7. Remote caller UI remains byte-oriented ANSI/CP437. -8. Ratatui remains local sysop UI only. -9. Door execution remains isolated from core session logic. -10. Do not bundle copyrighted or abandonware DOS doors. -11. Update docs in the same phase as behavior changes. -12. Run `./scripts/dev-check.sh` before marking any implementation phase done. - -## P0: Scope Freeze And ADR Baseline - -Status: Planned - -Objective: Turn the remaining post-v1.2 notes into explicit v1.3 decisions -before implementation starts. - -Implementation tasks: - -- Use ADR 0033 as the door compatibility scope boundary. -- Create or update ADRs for PETSCII/manual profile policy, transfer protocol - expansion, and FTN/OxideNet interoperability. -- Decide whether v1.3 is allowed to include transfer protocols outside ADR 0031. -- Decide whether OxideNet bridge/converter work is concrete implementation or - documentation-only boundary work. -- Decide whether remaining mailer checklist rows are stale, v1.3 scope, or - superseded by v1.2 implementation. -- Decide whether additional Wildcat/vendor drop files are required for v1.3 or - remain compatibility backlog. -- Update this plan if accepted scope differs from the current draft. - -Acceptance criteria: - -- Every v1.3 candidate has an accepted ADR or explicit release-plan decision. -- `design/TASKS.md` links to this plan for active v1.3 work. -- Implementation agents can pick a phase without re-litigating release scope. - -Validation: - -```bash -rg -n -i "post-v1\\.2|future|later|outside v1\\.2|PETSCII|YMODEM|XMODEM-1k|Wildcat|non-OxideBBS|FTN-to-internal" \ - README.md docs design config .github -``` - -## P1: Documentation Reconciliation And Task Tracker Update - -Status: Planned - -Objective: Remove contradictions left after v1.2 so v1.3 has one authoritative -scope story. - -Implementation tasks: - -- Add a `v1.3.0 Release Work` section to `design/TASKS.md` with this phase map. -- Update `design/PRD.md` and `design/ROADMAP.md` to point to this plan for - post-v1.2 candidate work. -- Clarify in door docs that v1.3 door work is compatibility with existing door - games, drop-file formats, provider behavior, and sysop tooling. -- Clarify which Wildcat and vendor-specific drop-file variants remain. -- Clarify whether non-OxideBBS participation and FTN-to-internal conversion were - completed as v1.2 boundaries or remain v1.3 implementation work. -- Reconcile `design/MAILER.md` checklist rows for scheduled polling, - external-mailer directory drop docs, and CLI status/queue views against the - current BinkP/FTN commands. - -Acceptance criteria: - -- Searches for stale v1.2/future language produce only historical references, - explicit v1.3 scope, or deliberate reserved-capacity notes. -- `design/TASKS.md`, `design/PRD.md`, `design/ROADMAP.md`, and this plan agree - on v1.3 status. -- No code behavior changes are made in this phase. - -Validation: - -```bash -rg -n -i "v1\\.3|post-v1\\.2|future|later|outside v1\\.2|deferred" \ - README.md docs design config .github -``` - -## P2: C64/PETSCII Terminal Completion - -Status: Planned - -Objective: Replace the current ASCII/PETSCII-friendly fallback with tested -PETSCII encode/decode support for C64-oriented caller profiles. - -Implementation tasks: - -- Define the supported PETSCII character set, control bytes, line endings, and - unsupported-character replacement policy in ADR 0034. -- Add full encode/decode tables and tests in the terminal layer. -- Keep CP437/ANSI behavior unchanged for ANSI and plain 80-column callers. -- Route C64 profile output through PETSCII-aware rendering where enabled. -- Add 40-column wrapping/truncation tests for generated menus, message lists, - message bodies, file lists, and logoff flow. -- Add fixture coverage for lowercase/uppercase mode, common punctuation, CR/LF, - backspace/delete, and unsupported glyph fallback. - -Acceptance criteria: - -- C64 callers can log in, navigate menus, read messages, view file lists, and - log off through PETSCII-aware rendering. -- Existing ANSI/CP437 snapshots remain stable or are intentionally updated. -- Tests prove PETSCII bytes are not accidentally treated as Unicode-first caller - UI. - -Validation: - -```bash -cargo test -p oxidebbs-term --locked petscii -cargo test -p oxidebbs-server --locked terminal -./scripts/dev-check.sh -``` - -## P3: Manual Terminal Profile Persistence - -Status: Planned - -Objective: Let callers or sysops persist terminal profile preference once the -user schema has an explicit terminal preference field. - -Implementation tasks: - -- Add a DecentDB migration for terminal profile preference on user/account - records. -- Define fallback order between telnet terminal-type detection, persisted user - preference, configured default profile, and manual session override. -- Add onboarding or account-settings flow for manual profile selection. -- Add sysop CLI and TUI edit support for terminal profile preference. -- Document config behavior and caller-facing profile choices. - -Acceptance criteria: - -- Existing users migrate with no forced terminal preference. -- A caller can choose ANSI/80-column, plain ASCII, or C64/40-column/PETSCII - where the flow is enabled. -- Persisted preference survives reconnect and overrides unreliable detection - according to ADR 0034. -- CLI, TUI, docs, config examples, and tests agree on valid profile names. - -Validation: - -```bash -cargo test -p oxidebbs-db --locked terminal -cargo test -p oxidebbs-core --locked terminal -cargo test -p oxidebbs-server --locked terminal -./scripts/dev-check.sh -``` - -## P4: Caller Transfer Protocol Decision And Expansion - -Status: Blocked - -Blocked by: ADR 0035. - -Objective: Decide whether v1.3 expands caller file-transfer protocols beyond -ZMODEM and XMODEM-CRC. - -Implementation tasks: - -- Review ADR 0031 and decide whether to supersede it. -- If expansion is accepted, choose exact protocol variants and exclude the rest. -- If YMODEM is accepted, define batch behavior, metadata handling, resume - behavior, cancellation, and caller-menu naming. -- If XMODEM-1k or checksum XMODEM is accepted, define negotiation, fallback, - and error-reporting behavior. -- Keep BinkP clearly separated from caller file-area transfer protocols. -- Update docs, config examples, caller menus, and transfer history if new - protocols are implemented. - -Acceptance criteria: - -- No new protocol is advertised until the protocol engine, caller flow, docs, - and tests exist. -- Existing ZMODEM and XMODEM-CRC behavior remains compatible. -- Unsupported protocols remain absent from caller menus and config examples. - -Validation: - -```bash -cargo test -p oxidebbs-transfer --locked -cargo test -p oxidebbs-server --locked file_transfer -./scripts/dev-check.sh -``` - -## P5: Door Drop-File Compatibility Expansion - -Status: Planned - -Objective: Finish or explicitly retire the remaining Wildcat/vendor-specific -drop-file compatibility notes. - -Implementation tasks: - -- Inventory which Wildcat and vendor-specific formats remain beyond the current - `DOOR.SYS`, `DORINFO1.DEF`, `CHAIN.TXT`, `DOORFILE.SR`, `PCBOARD.SYS`, and - `CALLINFO.BBS` renderers. -- Add exact CRLF byte-output renderers and tests for accepted formats. -- Add docs for selecting each format from TOML seeds and DecentDB door records. -- Keep copyrighted door packages and abandonware out of fixtures. -- If no additional formats are accepted, update `design/DOORS.md` and the - release plans to say v1.3 intentionally closes this compatibility note. - -Acceptance criteria: - -- Door docs and renderer list agree. -- Every accepted renderer has byte-exact tests. -- CLI/TUI door add/edit paths can select accepted formats. - -Validation: - -```bash -cargo test -p oxidebbs-door --locked drop -cargo test -p oxidebbs-server --locked doors -./scripts/dev-check.sh -``` - -## P6: FTN/OxideNet Interoperability Hardening - -Status: Blocked - -Blocked by: real-network/operator feedback or ADR 0036. - -Objective: Turn broad interoperability notes into concrete FTN/OxideNet bridge -behavior only where the project has enough requirements to avoid speculative -protocol work. - -Implementation tasks: - -- Decide whether Seen-by/PATH tuning needs implementation changes or only - operational documentation. -- Decide which additional outbound archive formats beyond ZIP are worth - supporting. -- Reconcile scheduled polling, external-mailer directory drop documentation, and - CLI status/queue checklist rows in `design/MAILER.md`. -- Reconcile v1.2 documentation about non-OxideBBS participation and an - FTN-to-internal converter. -- If bridge work is accepted, define packet mapping, address policy, origin - policy, duplicate detection, loop prevention, audit logging, and failure - handling. -- Add CLI/TUI status and diagnostics for accepted bridge workflows. - -Acceptance criteria: - -- Compatibility work is driven by documented operator feedback or ADR 0036. -- ZIP bundle behavior and existing FTN toss/scan/poll commands keep passing. -- Bridge/converter behavior cannot create message loops or bypass duplicate - detection. - -Validation: - -```bash -cargo test -p oxidebbs-ftn --locked -cargo test -p oxidebbs-oxidenet --locked -cargo test -p oxidebbs-server --locked net -./scripts/dev-check.sh -``` - -## P7: OxideNet Topology And Public-Network Expansion - -Status: Planned - -Objective: Decide how much reserved OxideNet topology becomes real operational -behavior in v1.3. - -Implementation tasks: - -- Confirm whether backup hub activation and multi-hub topology management are - implementation scope or reserved-address documentation only. -- Confirm whether `42:2/*`, `42:3/*`, and `42:100/*` remain future ranges or - become assignable/validated ranges. -- Define policy-authority group behavior if governance is no longer single-admin. -- Add DNS/BinkP reachability validation for public listings if public listing is - accepted. -- Document operator runbooks for any public-network behavior implemented in - v1.3. - -Acceptance criteria: - -- Address validation, config-package generation, nodelist publication, and TUI - views agree on accepted ranges. -- Suspended-node and credential-rotation behavior remain enforced. -- Reserved ranges that are not implemented remain clearly labeled as reserved, - not incomplete v1.3 work. - -Validation: - -```bash -cargo test -p oxidebbs-oxidenet --locked -cargo test -p oxidebbs-server --locked oxidenet -npm run docs:build -./scripts/dev-check.sh -``` - -## P8: Final Integration And Release Readiness - -Status: Planned - -Objective: Prove the accepted v1.3 scope is complete, documented, and releasable -without stale future/backlog language describing shipped behavior as absent. - -Implementation tasks: - -- Update crate versions and docs package metadata for `1.3.0` when release - preparation begins. -- Update changelog entries and operator compatibility notes. -- Update `README.md`, `SECURITY.md`, `design/TASKS.md`, and public docs for the - accepted v1.3 scope. -- Run stale wording scans and reconcile any hits that describe implemented v1.3 - behavior as future, absent, or outside scope. -- Run Rust validation, docs build, Docker smoke, release dry-run, and package - smoke checks. - -Acceptance criteria: - -- Every phase in this plan is `Complete` or explicitly moved out of v1.3 by ADR. -- `./scripts/dev-check.sh` passes. -- `npm run docs:build` passes. -- Local release-package smoke checks pass. -- No tags, pushes, GitHub releases, or hosted publication steps are performed - without explicit maintainer approval in the current conversation. - -Validation: - -```bash -./scripts/dev-check.sh -cd docs && npm run docs:build -rg -n -i "post-v1\\.2|future|later|outside v1\\.2|deferred|not implemented|not yet|partial|blocked|incomplete" \ - README.md docs design config .github -``` - -## Approval-Gated Publication - -These steps remain pending until the maintainer explicitly approves tag creation -and release publication in the current conversation: - -- [ ] Create and push tag `v1.3.0`. -- [ ] Publish the GitHub release. -- [ ] Confirm hosted Linux, macOS, and Windows release archives and checksums. -- [ ] Download at least one hosted artifact and repeat package smoke testing. -- [ ] Confirm the docs site deployment after publication. - -## Final Recommendation - -Use v1.3 as a focused compatibility release, not another large deferred-scope -sweep. The highest-confidence scope is PETSCII/manual terminal completion, door -drop-file compatibility, documentation reconciliation, and explicit decisions -for transfer-protocol and FTN/OxideNet compatibility work. Any protocol -expansion without a new ADR should remain blocked. +Closed: 2026-08-05 + +## What v1.3.0 Actually Shipped + +The original draft of this plan scoped `v1.3.0` as a broad post-v1.2 +compatibility release (phases P0–P8). When release preparation began, the +completed work in the changelog's `Unreleased` section was released as +`v1.3.0` under the SemVer highest-impact rule in +[`design/VERSIONING_GUIDE.md`](./VERSIONING_GUIDE.md): + +- Full PETSCII encode/decode for the C64 terminal profile (phase P2 core; + ADR 0034), including `TerminalCharset::Petscii`, config support, and + charset-aware caller output at the central encoding chokepoint. +- CP437 low-range glyph decode/encode (`0x01..=0x1F`, `0x7F`) in + `oxidebbs-term`. +- ECMA-48 private parameter byte support in the ANSI parser. +- ANSI parser bounds/overflow hardening, `insert_network_path` rollback + atomicity, and transactional migrations 5→6 and 6→7. +- Workspace dependency hygiene: crate versions centralized under + `[workspace.package]`, unused dependencies removed. +- Documentation corrections across `AGENTS.md`, `design/ARCHITECTURE.md`, + `design/SPEC.md`, and the Rust code-generation skill. + +The authoritative record is `docs/about/changelog.md` `[1.3.0] - 2026-08-05` +and the P2 checklist in [`design/TASKS.md`](./TASKS.md). + +## What Moved To v1.4 + +The remaining phases from this plan's original scope — manual terminal profile +persistence (P3), caller transfer protocol decision (P4), door drop-file +compatibility expansion (P5), FTN/OxideNet interoperability hardening (P6), +OxideNet topology expansion (P7), and the associated documentation +reconciliation (P0/P1 remainder) — now live in +[`design/RELEASE_v1_4_PLAN.md`](./RELEASE_v1_4_PLAN.md), which also carries the +current phase status map and ADR table. + +The original draft text of this plan is preserved in git history prior to the +v1.3.0 release. diff --git a/design/RELEASE_v1_4_PLAN.md b/design/RELEASE_v1_4_PLAN.md new file mode 100644 index 0000000..0543976 --- /dev/null +++ b/design/RELEASE_v1_4_PLAN.md @@ -0,0 +1,505 @@ +# OxideBBS v1.4 Release Plan + +Document status: Planning draft + +Created: 2026-08-05 (reconciled from `design/RELEASE_v1_3_PLAN.md`) + +Last reconciled: 2026-08-05 + +Release intent: `v1.4.0` is the post-v1.3 compatibility release. It carries +forward the remaining door, terminal-profile, transfer-protocol, and +FTN/OxideNet compatibility scope that did not ship in v1.3.0. + +`v1.3.0` shipped on 2026-08-05 containing the C64/PETSCII terminal core (ADR +0034), CP437 low-range glyph support, ANSI parser hardening, and workspace +dependency hygiene. See `docs/about/changelog.md` and the closed +[`design/RELEASE_v1_3_PLAN.md`](./RELEASE_v1_3_PLAN.md) for what that release +contained. This plan does not reopen v1.3.0 or v1.2.x completion; features +marked complete in earlier release plans remain their respective baselines. +v1.4 work must be scoped by new ADRs where existing ADRs deliberately kept +behavior out of earlier releases. + +## Phase Status Map + +Status values: + +- `Complete`: the planning artifact exists and no code work remains for that + planning phase. +- `Partial`: some foundation, scaffolding, documentation, or narrow behavior + exists, but the phase exit gate is not fully satisfied. +- `Planned`: ready for implementation, no coding started. +- `Active`: implementation is underway. +- `Blocked`: implementation cannot proceed until the named dependency changes. +- `Deferred`: the phase was explicitly moved out of v1.4 by a + maintainer-approved ADR or release-plan decision. + +| Phase | Title | Status | Exit Gate | +| --- | --- | --- | --- | +| P0 | Scope freeze and ADR baseline | Partial | ADR 0034 accepted (PETSCII core shipped in v1.3.0). ADRs still needed for transfer protocol expansion and FTN/OxideNet compatibility boundaries. | +| P1 | Documentation reconciliation and task tracker update | Active | v1.3/v1.4 docs no longer contradict each other about door compatibility, Wildcat variants, OxideNet converter work, or transfer protocol scope. | +| P2 | C64/PETSCII terminal completion | Complete | Shipped in v1.3.0: full PETSCII encode/decode, `TerminalCharset::Petscii`, C64 profile routing, and tests. See ADR 0034 and `design/TASKS.md`. | +| P3 | Manual terminal profile persistence | Planned | User/account schema stores terminal preference, onboarding/manual selection can persist it, and telnet detection/default-profile fallback order is documented and tested. | +| P4 | Caller transfer protocol decision and expansion | Blocked | A maintainer-approved ADR supersedes or preserves ADR 0031 for YMODEM, XMODEM-1k, checksum XMODEM, and related caller-transfer variants. | +| P5 | Door drop-file compatibility expansion | Planned | Current drop-file docs and renderers agree; additional Wildcat/vendor-specific variants are either implemented with byte-exact tests or explicitly deferred by ADR. | +| P6 | FTN/OxideNet interoperability hardening | Blocked | Real-network/operator feedback or a maintainer-approved compatibility ADR defines Seen-by/PATH tuning, archive-format expansion, mailer scheduling/status gaps, and non-OxideBBS bridge expectations. | +| P7 | OxideNet topology and public-network expansion | Planned | Backup hub, future net ranges, policy-authority workflow, public listing, and reachability validation are either implemented or explicitly kept as reserved capacity. | +| P8 | Final integration and release readiness | Planned | Rust gate, docs build, stale wording scan, release notes, version metadata, and local package smoke checks pass for v1.4. | + +## Reviewed Documentation + +This plan is based on a review of: + +- `README.md` +- `docs/**/*.md` +- `design/RELEASE_v1_1_PLAN.md` +- `design/RELEASE_v1_2_PLAN.md` +- `design/RELEASE_v1_3_PLAN.md` +- `design/TASKS.md` +- `design/PRD.md` +- `design/ROADMAP.md` +- `design/SPEC.md` +- `design/TELNET.md` +- `design/ANSI_CP437.md` +- `design/DOORS.md` +- `design/DOOR_GAME_RESOURCES.md` +- `design/FILE_TRANSFERS.md` +- `design/MAILER.md` +- `design/FTN_PLAN.md` +- `design/OXIDENET_PRD.md` +- `design/adr/*.md` + +Third-party package documentation under `node_modules/` was intentionally not +treated as OxideBBS product scope. + +## ADRs For v1.4 + +| ADR | Topic | Status | Used By | +| --- | --- | --- | --- | +| ADR 0033 | Door compatibility scope | Accepted | P0, P1, P5 | +| ADR 0034 | PETSCII translation and terminal-profile persistence policy | Accepted (PETSCII core shipped in v1.3.0; P3 persistence remains) | P2, P3 | +| ADR 0035 | Caller file-transfer protocol expansion decision | Proposed placeholder | P4 | +| ADR 0036 | FTN/OxideNet interoperability and bridge policy | Proposed placeholder | P6, P7 | + +If other ADRs are created before the placeholders, renumber the placeholder +rows before implementation starts. + +## v1.4 Candidate Coverage Matrix + +Every row below must either be implemented, tested, and documented before v1.4 +is declared complete, or explicitly moved out of v1.4 by a maintainer-approved +ADR (recorded as `Deferred` in the phase map). + +| Candidate Item | Source Documents | v1.4 Phase | +| --- | --- | --- | +| ~~Full PETSCII encode/decode rendering beyond ASCII fallback~~ Shipped in v1.3.0 | `TASKS.md`, `SPEC.md`, `TELNET.md`, `ANSI_CP437.md`, `PRD.md` | P2 (Complete) | +| Persist manual terminal profile selection in user/account settings | `TASKS.md`, `SPEC.md`, `TELNET.md`, ADR 0034 | P3 | +| YMODEM and XMODEM-1k reconsideration | `FILE_TRANSFERS.md`, ADR 0031, `docs/project/file-transfers.md` | P4 | +| Checksum XMODEM, XMODEM-g, ZedZap/ZMODEM-8K, Kermit, and other transfer variants | `FILE_TRANSFERS.md`, ADR 0031 | P4 | +| Additional Wildcat and vendor-specific drop-file variants | `DOORS.md`, `DOOR_GAME_RESOURCES.md`, `RELEASE_v1_1_PLAN.md`, `RELEASE_v1_2_PLAN.md` | P5 | +| Seen-by and PATH interoperability tuning after real-network feedback | `FTN_PLAN.md`, `OXIDENET_PRD.md`, `RELEASE_v1_1_PLAN.md` | P6 | +| Additional outbound bundle archive formats beyond ZIP | `FTN_PLAN.md`, ADR 0028 | P6 | +| Scheduled polling, external-mailer directory drop docs, and mailer CLI status/queue checklist reconciliation | `MAILER.md`, `RELEASE_v1_2_PLAN.md` | P1, P6 | +| Non-OxideBBS OxideNet participation and FTN-to-internal converter reconciliation | `FTN_PLAN.md`, `OXIDENET_PRD.md`, `RELEASE_v1_2_PLAN.md` | P1, P6 | +| OxideNet backup hub, future net ranges, and public-network topology expansion | `OXIDENET_PRD.md`, `docs/oxidenet/addressing.md`, `RELEASE_v1_2_PLAN.md` | P7 | + +## Known Scope Tensions + +The v1.4 scope starts with several documentation tensions that must be resolved +before implementation agents treat the plan as executable: + +- ADR 0033 defines the door compatibility boundary. v1.4 door work is limited + to compatibility with existing door games, drop-file formats, provider + behavior, and sysop tooling. +- `design/RELEASE_v1_2_PLAN.md` says P5 completed Wildcat/PCBoard drop-file + coverage, while `design/DOORS.md` says additional Wildcat and vendor-specific + variants remain future compatibility work. v1.4 must identify exact remaining + variants or move them out of active scope. +- `design/RELEASE_v1_2_PLAN.md` says P15 covered non-OxideBBS participation + boundaries and an FTN-to-internal converter, while `design/FTN_PLAN.md` and + `design/OXIDENET_PRD.md` still describe those as post-v1.2 or later + compatibility. v1.4 must reconcile whether v1.2 delivered documentation + boundaries only, or whether concrete bridge implementation is still intended. +- `design/MAILER.md` still has unchecked implementation checklist rows for + scheduled polling, external-mailer directory drop documentation, and CLI + status/queue views, while the v1.2 release plan marks BinkP and FTN + operations complete. v1.4 must decide whether those rows are stale checklist + text, v1.4 compatibility work, or intentionally out of scope. +- ADR 0031 explicitly keeps YMODEM, XMODEM-1k, checksum XMODEM, ZedZap, and + similar caller-transfer variants outside v1.2 unless a later ADR supersedes + it. v1.4 cannot implement or advertise those protocols until P4 accepts a new + transfer policy. + +Resolved since the v1.3 draft of this scope: + +- ADR 0034 was accepted and its PETSCII encode/decode core shipped in v1.3.0; + only the terminal-profile persistence work (P3) remains open from that ADR. + +## Global Implementation Rules + +These rules apply to every phase: + +1. Use Rust edition 2024. +2. Keep DecentDB as the only database. +3. Use `cargo add` for new dependencies; do not hand-edit versions. +4. Keep shared dependency versions in root `[workspace.dependencies]`. +5. No `unwrap()` or `expect()` in library code. +6. Never hold a lock across `.await`. +7. Remote caller UI remains byte-oriented ANSI/CP437. +8. Ratatui remains local sysop UI only. +9. Door execution remains isolated from core session logic. +10. Do not bundle copyrighted or abandonware DOS doors. +11. Update docs in the same phase as behavior changes. +12. Run `./scripts/dev-check.sh` before marking any implementation phase done. +13. Database migrations must be atomic: each migration runs in a single + transaction so a failed migration cannot leave the schema between versions + (lesson from the v1.3.0 review fixes to migrations 5→6 and 6→7). + +## P0: Scope Freeze And ADR Baseline + +Status: Partial + +Objective: Turn the remaining post-v1.3 notes into explicit v1.4 decisions +before implementation starts. + +Implementation tasks: + +- [x] Accept ADR 0034 (PETSCII translation and terminal-profile persistence + policy); PETSCII core shipped in v1.3.0. +- Use ADR 0033 as the door compatibility scope boundary. +- Create or update ADRs for transfer protocol expansion (ADR 0035) and + FTN/OxideNet interoperability (ADR 0036). +- Decide whether v1.4 is allowed to include transfer protocols outside ADR 0031. +- Decide whether OxideNet bridge/converter work is concrete implementation or + documentation-only boundary work. +- Decide whether remaining mailer checklist rows are stale, v1.4 scope, or + superseded by v1.2 implementation. +- Decide whether additional Wildcat/vendor drop files are required for v1.4 or + remain compatibility backlog. +- Update this plan if accepted scope differs from the current draft. + +Acceptance criteria: + +- Every v1.4 candidate has an accepted ADR or explicit release-plan decision. +- `design/TASKS.md` links to this plan for active v1.4 work. +- Implementation agents can pick a phase without re-litigating release scope. + +Validation (release-facing files only; historical release plans and ADRs keep +their original wording by design): + +```bash +rg -n -i "post-v1\\.3|outside v1\\.3|PETSCII|YMODEM|XMODEM-1k|Wildcat|non-OxideBBS|FTN-to-internal" \ + README.md docs design/TASKS.md design/PRD.md design/ROADMAP.md config .github +``` + +## P1: Documentation Reconciliation And Task Tracker Update + +Status: Active + +Objective: Remove contradictions left after v1.3 so v1.4 has one authoritative +scope story. + +Implementation tasks: + +- [x] Point `design/TASKS.md` active-work section at this plan for v1.4. +- [x] Update `design/PRD.md` to point to this plan for post-v1.3 candidate work. +- Update `design/ROADMAP.md` to point to this plan for post-v1.3 candidate work. +- Clarify in door docs that v1.4 door work is compatibility with existing door + games, drop-file formats, provider behavior, and sysop tooling. +- Clarify which Wildcat and vendor-specific drop-file variants remain. +- Clarify whether non-OxideBBS participation and FTN-to-internal conversion were + completed as v1.2 boundaries or remain v1.4 implementation work. +- Reconcile `design/MAILER.md` checklist rows for scheduled polling, + external-mailer directory drop docs, and CLI status/queue views against the + current BinkP/FTN commands. + +Acceptance criteria: + +- Searches of release-facing files for stale v1.3/future language produce only + historical references, explicit v1.4 scope, or deliberate reserved-capacity + notes. +- `design/TASKS.md`, `design/PRD.md`, `design/ROADMAP.md`, and this plan agree + on v1.4 status. +- No code behavior changes are made in this phase. + +Validation: + +```bash +rg -n -i "v1\\.4|post-v1\\.3|outside v1\\.3|deferred" \ + README.md docs design/TASKS.md design/PRD.md design/ROADMAP.md config .github +``` + +## P2: C64/PETSCII Terminal Completion + +Status: Complete — shipped in v1.3.0 (2026-08-05). + +This phase was completed under the v1.3 plan and released as part of v1.3.0: + +- Full PETSCII encode/decode tables and tests in `oxidebbs-term` + (`decode_petscii`, `petscii_byte_to_char`, `char_to_petscii_byte`, + `render_petscii`, `render_petscii_lossy`). +- `TerminalCharset::Petscii` (config string `"petscii"`) with the built-in C64 + profile defaulting to it; `petscii_ascii_fallback` remains supported. +- C64 caller output routed through PETSCII-aware rendering at the central + `encode_text_into` chokepoint; ANSI/CP437 and plain-ASCII behavior unchanged; + binary file-transfer and telnet negotiation bytes never re-encoded. +- Round-trip, box-drawing, lossy-replacement, 40-column capability, and + capability-negotiation tests in `oxidebbs-term` and `oxidebbs-server`. + +Authoritative record: ADR 0034, `docs/about/changelog.md` `[1.3.0]`, and the +P2 checklist in `design/TASKS.md`. + +## P3: Manual Terminal Profile Persistence + +Status: Planned + +Objective: Let callers or sysops persist terminal profile preference once the +user schema has an explicit terminal preference field. + +Implementation tasks: + +- Add a DecentDB migration for terminal profile preference on user/account + records (`SCHEMA_VERSION` 10 → 11). The migration must be atomic per global + rule 13. +- Define fallback order between telnet terminal-type detection, persisted user + preference, configured default profile, and manual session override per + ADR 0034. +- Add onboarding or account-settings flow for manual profile selection. +- Add sysop CLI and TUI edit support for terminal profile preference. +- Document config behavior and caller-facing profile choices. + +Acceptance criteria: + +- Existing users migrate with no forced terminal preference. +- The migration is transactional: a mid-migration failure rolls the schema + back to version 10 with no partial columns. +- A caller can choose ANSI/80-column, plain ASCII, or C64/40-column/PETSCII + where the flow is enabled. +- Persisted preference survives reconnect and overrides unreliable detection + according to ADR 0034. +- CLI, TUI, docs, config examples, and tests agree on valid profile names. + +Validation: + +```bash +cargo test -p oxidebbs-db --locked terminal +cargo test -p oxidebbs-core --locked terminal +cargo test -p oxidebbs-server --locked terminal +./scripts/dev-check.sh +``` + +## P4: Caller Transfer Protocol Decision And Expansion + +Status: Blocked + +Blocked by: ADR 0035. + +Objective: Decide whether v1.4 expands caller file-transfer protocols beyond +ZMODEM and XMODEM-CRC. + +Implementation tasks: + +- Review ADR 0031 and decide whether to supersede it. +- If expansion is accepted, choose exact protocol variants and exclude the rest. +- If YMODEM is accepted, define batch behavior, metadata handling, resume + behavior, cancellation, and caller-menu naming. +- If XMODEM-1k or checksum XMODEM is accepted, define negotiation, fallback, + and error-reporting behavior. +- Keep BinkP clearly separated from caller file-area transfer protocols. +- Update docs, config examples, caller menus, and transfer history if new + protocols are implemented. +- If expansion is rejected, record ADR 0035 as preserving ADR 0031 and mark + this phase `Deferred`. + +Acceptance criteria: + +- No new protocol is advertised until the protocol engine, caller flow, docs, + and tests exist. +- Existing ZMODEM and XMODEM-CRC behavior remains compatible. +- Unsupported protocols remain absent from caller menus and config examples. + +Validation: + +```bash +cargo test -p oxidebbs-transfer --locked +cargo test -p oxidebbs-server --locked file_transfer +./scripts/dev-check.sh +``` + +## P5: Door Drop-File Compatibility Expansion + +Status: Planned + +Objective: Finish or explicitly retire the remaining Wildcat/vendor-specific +drop-file compatibility notes. + +Implementation tasks: + +- Inventory which Wildcat and vendor-specific formats remain beyond the current + `DOOR.SYS`, `DORINFO1.DEF`, `CHAIN.TXT`, `DOORFILE.SR`, `PCBOARD.SYS`, and + `CALLINFO.BBS` renderers. +- Add exact CRLF byte-output renderers and tests for accepted formats. +- Add docs for selecting each format from TOML seeds and DecentDB door records. +- Keep copyrighted door packages and abandonware out of fixtures. +- If no additional formats are accepted, update `design/DOORS.md` and the + release plans to say v1.4 intentionally closes this compatibility note, and + mark this phase `Deferred`. + +Acceptance criteria: + +- Door docs and renderer list agree. +- Every accepted renderer has byte-exact tests. +- CLI/TUI door add/edit paths can select accepted formats. + +Validation: + +```bash +cargo test -p oxidebbs-door --locked drop +cargo test -p oxidebbs-server --locked doors +./scripts/dev-check.sh +``` + +## P6: FTN/OxideNet Interoperability Hardening + +Status: Blocked + +Blocked by: real-network/operator feedback or ADR 0036. + +Objective: Turn broad interoperability notes into concrete FTN/OxideNet bridge +behavior only where the project has enough requirements to avoid speculative +protocol work. + +Implementation tasks: + +- Open a GitHub tracking issue (or equivalent operator-feedback channel) for + Seen-by/PATH behavior, archive-format needs, and bridge requirements, and + link it from `design/MAILER.md` and `design/FTN_PLAN.md`. If no actionable + feedback exists by v1.4 feature freeze, close the bridge-implementation + portion of this phase as `Deferred` by ADR 0036 rather than leaving it + permanently blocked. +- Decide whether Seen-by/PATH tuning needs implementation changes or only + operational documentation. +- Decide which additional outbound archive formats beyond ZIP are worth + supporting. +- Reconcile scheduled polling, external-mailer directory drop documentation, and + CLI status/queue checklist rows in `design/MAILER.md`. +- Reconcile v1.2 documentation about non-OxideBBS participation and an + FTN-to-internal converter. +- If bridge work is accepted, define packet mapping, address policy, origin + policy, duplicate detection, loop prevention, audit logging, and failure + handling. +- Add CLI/TUI status and diagnostics for accepted bridge workflows. + +Acceptance criteria: + +- Compatibility work is driven by documented operator feedback or ADR 0036. +- ZIP bundle behavior and existing FTN toss/scan/poll commands keep passing. +- Bridge/converter behavior cannot create message loops or bypass duplicate + detection. + +Validation: + +```bash +cargo test -p oxidebbs-ftn --locked +cargo test -p oxidebbs-oxidenet --locked +cargo test -p oxidebbs-server --locked net +./scripts/dev-check.sh +``` + +## P7: OxideNet Topology And Public-Network Expansion + +Status: Planned + +Objective: Decide how much reserved OxideNet topology becomes real operational +behavior in v1.4. + +Implementation tasks: + +- Confirm whether backup hub activation and multi-hub topology management are + implementation scope or reserved-address documentation only. +- Confirm whether `42:2/*`, `42:3/*`, and `42:100/*` remain future ranges or + become assignable/validated ranges. +- Define policy-authority group behavior if governance is no longer single-admin. +- Add DNS/BinkP reachability validation for public listings if public listing is + accepted. +- Document operator runbooks for any public-network behavior implemented in + v1.4. + +Acceptance criteria: + +- Address validation, config-package generation, nodelist publication, and TUI + views agree on accepted ranges. +- Suspended-node and credential-rotation behavior remain enforced. +- Reserved ranges that are not implemented remain clearly labeled as reserved, + not incomplete v1.4 work. + +Validation: + +```bash +cargo test -p oxidebbs-oxidenet --locked +cargo test -p oxidebbs-server --locked oxidenet +npm run docs:build +./scripts/dev-check.sh +``` + +## P8: Final Integration And Release Readiness + +Status: Planned + +Objective: Prove the accepted v1.4 scope is complete, documented, and +releasable without stale future/backlog language describing shipped behavior as +absent. + +Implementation tasks: + +- When release preparation begins, bump release metadata with + `scripts/bump-version.sh 1.4.0` and follow `design/VERSIONING_GUIDE.md` + (root `Cargo.toml [workspace.package]`, `VERSION`, `Cargo.lock`, + `package.json`/lock, `compose.yaml`; crate manifests no longer carry their + own versions). +- Update `docs/about/changelog.md` entries and operator compatibility notes + (keep an `## [Unreleased]` section; the bump script requires it). +- Update `README.md`, `SECURITY.md`, `design/TASKS.md`, and public docs for the + accepted v1.4 scope. +- Run stale wording scans and reconcile any hits that describe implemented v1.4 + behavior as future, absent, or outside scope. +- Run Rust validation, docs build, Docker smoke, release dry-run, and package + smoke checks. + +Acceptance criteria: + +- Every phase in this plan is `Complete` or `Deferred` by ADR. +- `./scripts/dev-check.sh` passes. +- `npm run docs:build` passes (run from the repository root; the script lives + in the root `package.json`). +- Local release-package smoke checks pass. +- No tags, pushes, GitHub releases, or hosted publication steps are performed + without explicit maintainer approval in the current conversation. + +Validation: + +```bash +./scripts/dev-check.sh +npm run docs:build +rg -n -i "post-v1\\.3|outside v1\\.3|deferred|not implemented|not yet" \ + README.md docs design/TASKS.md design/PRD.md design/ROADMAP.md config .github +``` + +## Approval-Gated Publication + +These steps remain pending until the maintainer explicitly approves tag creation +and release publication in the current conversation: + +- [ ] Create and push tag `v1.4.0`. +- [ ] Publish the GitHub release. +- [ ] Confirm hosted Linux, macOS, and Windows release archives and checksums. +- [ ] Download at least one hosted artifact and repeat package smoke testing. +- [ ] Confirm the docs site deployment after publication. + +## Final Recommendation + +Use v1.4 as a focused compatibility release, not another large deferred-scope +sweep. The highest-confidence scope is manual terminal-profile persistence +(P3), door drop-file compatibility (P5), documentation reconciliation (P1), +and explicit ADR decisions for transfer-protocol (P4) and FTN/OxideNet (P6) +compatibility work — including deciding to defer them. Any protocol expansion +without a new ADR should remain blocked. diff --git a/design/TASKS.md b/design/TASKS.md index 0cbed77..b4f1c1f 100644 --- a/design/TASKS.md +++ b/design/TASKS.md @@ -4,10 +4,13 @@ This file tracks active release work and near-term follow-up items. It is not a replacement for `design/ROADMAP.md`, `docs/about/changelog.md`, or ADRs; it is a short operational checklist for work that needs explicit closure. -## v1.3.0 Release Work +## v1.4.0 Release Work -Active work follows [`design/RELEASE_v1_3_PLAN.md`](./RELEASE_v1_3_PLAN.md). +Active work follows [`design/RELEASE_v1_4_PLAN.md`](./RELEASE_v1_4_PLAN.md). Phase status lives in that plan; this section tracks concrete closure items. +v1.3.0 shipped on 2026-08-05; its record lives in the closed +[`design/RELEASE_v1_3_PLAN.md`](./RELEASE_v1_3_PLAN.md) and +`docs/about/changelog.md`. | Phase | Title | Status | | --- | --- | --- | @@ -23,6 +26,8 @@ Phase status lives in that plan; this section tracks concrete closure items. ### P2: C64/PETSCII Terminal Completion +Shipped in v1.3.0 (2026-08-05); see ADR 0034 and `docs/about/changelog.md`. + - [x] Author ADR 0034 (PETSCII translation and terminal-profile persistence policy). - [x] Add full PETSCII encode/decode tables and tests in `oxidebbs-term`. - [x] Add `TerminalCharset::Petscii` and make the C64 profile default to it.