From f69c893a401730339ad72610c573e20282573045 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Mon, 22 Jun 2026 16:12:57 +0400 Subject: fix: harden path lookup and mark gl backend gap --- adapters/fparkan-platform-sdl/src/lib.rs | 34 ++++---- adapters/fparkan-render-gl/src/lib.rs | 12 +-- adr/ADR-0007-safe-sdl-opengl.md | 23 +++++- crates/fparkan-path/src/lib.rs | 27 +++++- crates/fparkan-vfs/src/lib.rs | 137 +++++++++++++++++++++++++++---- docs/appendices/knowledge-boundaries.md | 19 +++-- fixtures/acceptance/coverage.tsv | 4 +- 7 files changed, 209 insertions(+), 47 deletions(-) diff --git a/adapters/fparkan-platform-sdl/src/lib.rs b/adapters/fparkan-platform-sdl/src/lib.rs index 73aea1f..f573885 100644 --- a/adapters/fparkan-platform-sdl/src/lib.rs +++ b/adapters/fparkan-platform-sdl/src/lib.rs @@ -1,5 +1,5 @@ #![forbid(unsafe_code)] -//! SDL platform adapter proof behind safe `FParkan` ports. +//! SDL platform adapter boundary stubs behind safe `FParkan` ports. use fparkan_platform::{ EventSource, GraphicsContextRequest, GraphicsProfile, PhysicalSize, PlatformError, @@ -33,20 +33,20 @@ impl Default for SdlAdapterCapabilities { } } -/// Returns adapter readiness status for the safe project-owned layer. +/// Returns whether the project-owned adapter boundary avoids `unsafe`. #[must_use] -pub fn safe_adapter_ready() -> bool { +pub fn project_owned_layer_unsafe_free() -> bool { SdlAdapterCapabilities::default().project_owned_unsafe_free } -/// In-memory event source used by adapter smoke tests and composition roots -/// before a concrete SDL runtime is injected. +/// In-memory event source used by adapter smoke tests before a concrete SDL +/// runtime is selected. #[derive(Clone, Debug, Default)] -pub struct SdlEventSourceProof { +pub struct SdlEventSourceStub { pending: Vec, } -impl SdlEventSourceProof { +impl SdlEventSourceStub { /// Creates an event source with deterministic pending events. #[must_use] pub fn new(pending: Vec) -> Self { @@ -54,22 +54,22 @@ impl SdlEventSourceProof { } } -impl EventSource for SdlEventSourceProof { +impl EventSource for SdlEventSourceStub { fn poll(&mut self, out: &mut Vec) -> Result<(), PlatformError> { out.append(&mut self.pending); Ok(()) } } -/// Safe window-port proof with SDL-compatible drawable-size semantics. +/// Safe window-port stub with SDL-compatible drawable-size semantics. #[derive(Clone, Debug, Eq, PartialEq)] -pub struct SdlWindowProof { +pub struct SdlWindowStub { size: PhysicalSize, presents: u64, } -impl SdlWindowProof { - /// Creates a proof window with a fixed drawable size. +impl SdlWindowStub { + /// Creates a stub window with a fixed drawable size. #[must_use] pub fn new(size: PhysicalSize) -> Self { Self { size, presents: 0 } @@ -82,7 +82,7 @@ impl SdlWindowProof { } } -impl WindowPort for SdlWindowProof { +impl WindowPort for SdlWindowStub { fn drawable_size(&self) -> PhysicalSize { self.size } @@ -98,20 +98,20 @@ mod tests { use super::*; #[test] - fn adapter_reports_safe_project_layer_ready() { - assert!(safe_adapter_ready()); + fn adapter_boundary_is_project_owned_unsafe_free() { + assert!(project_owned_layer_unsafe_free()); assert_eq!(SdlAdapterCapabilities::default().graphics.len(), 2); } #[test] fn event_source_and_window_ports_are_deterministic() -> Result<(), PlatformError> { - let mut source = SdlEventSourceProof::new(vec![PlatformEvent::Quit]); + let mut source = SdlEventSourceStub::new(vec![PlatformEvent::Quit]); let mut events = Vec::new(); source.poll(&mut events)?; source.poll(&mut events)?; assert_eq!(events, vec![PlatformEvent::Quit]); - let mut window = SdlWindowProof::new(PhysicalSize { + let mut window = SdlWindowStub::new(PhysicalSize { width: 320, height: 240, }); diff --git a/adapters/fparkan-render-gl/src/lib.rs b/adapters/fparkan-render-gl/src/lib.rs index 094b1ad..94bf761 100644 --- a/adapters/fparkan-render-gl/src/lib.rs +++ b/adapters/fparkan-render-gl/src/lib.rs @@ -1,5 +1,5 @@ #![forbid(unsafe_code)] -//! OpenGL render adapter proof behind safe `FParkan` render ports. +//! OpenGL render adapter boundary stubs behind safe `FParkan` render ports. use fparkan_render::{ canonical_capture, FrameOutput, RenderBackend, RenderCommandList, RenderError, @@ -64,9 +64,9 @@ impl Default for GlAdapterCapabilities { } } -/// Returns adapter readiness status for the safe project-owned layer. +/// Returns whether the project-owned adapter boundary avoids `unsafe`. #[must_use] -pub fn safe_adapter_ready() -> bool { +pub fn project_owned_layer_unsafe_free() -> bool { GlAdapterCapabilities::default().project_owned_unsafe_free } @@ -98,7 +98,7 @@ pub fn compile_shader_source( Ok(()) } -/// Safe render backend facade used for adapter-level command validation. +/// Safe render backend stub used for adapter-level command validation. /// /// A concrete OpenGL implementation can be injected behind the same /// [`RenderBackend`] port once an audited safe GL facade is selected. This type @@ -147,8 +147,8 @@ mod tests { }; #[test] - fn adapter_reports_safe_project_layer_ready() { - assert!(safe_adapter_ready()); + fn adapter_boundary_is_project_owned_unsafe_free() { + assert!(project_owned_layer_unsafe_free()); assert_eq!(GlAdapterCapabilities::default().profiles.len(), 2); } diff --git a/adr/ADR-0007-safe-sdl-opengl.md b/adr/ADR-0007-safe-sdl-opengl.md index f82c424..01e35e4 100644 --- a/adr/ADR-0007-safe-sdl-opengl.md +++ b/adr/ADR-0007-safe-sdl-opengl.md @@ -2,4 +2,25 @@ Status: provisional -Workspace-owned code forbids `unsafe`. SDL/OpenGL adapters must use maintained external crates behind a safe project API. The current repository still contains legacy demo code scheduled for replacement; new adapter crates are placeholders until a fully audited safe facade is selected and proven on all target profiles. +Workspace-owned code forbids `unsafe`. SDL/OpenGL adapters must use maintained +external crates behind a safe project API; local Objective-C/CGL/SDL/OpenGL FFI +inside FParkan is not an acceptable implementation strategy. + +The current adapter crates are safe boundary stubs. They compile the intended +ports and deterministic command contracts, but they do not create SDL windows, +GL contexts, GPU resources, shaders, draw calls, swapchains, or presents. They +must not be treated as backend readiness evidence. + +To close the macOS backend requirement, choose and vendor/lock a maintained +safe facade stack, then implement: + +- SDL event source, window creation, GL context lifecycle, drawable size and + present; +- GL shader compile/link, buffer/texture upload, render state, draw calls and + diagnostics; +- game/viewer composition roots using those adapters; +- hidden-window/offscreen macOS smoke tests and licensed local model/terrain + frame captures. + +Until those are implemented, Desktop GL evidence may document external probes +only; it does not satisfy the permanent adapter requirement. diff --git a/crates/fparkan-path/src/lib.rs b/crates/fparkan-path/src/lib.rs index d15aae8..f59fda0 100644 --- a/crates/fparkan-path/src/lib.rs +++ b/crates/fparkan-path/src/lib.rs @@ -110,7 +110,7 @@ impl std::error::Error for PathError {} /// Returns [`PathError`] when the input is empty, absolute, contains an /// embedded NUL, attempts parent traversal, or is not valid UTF-8 after /// legacy separator normalization. -pub fn normalize_relative(raw: &[u8], _policy: PathPolicy) -> Result { +pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result { if raw.is_empty() { return Err(PathError::Empty); } @@ -124,11 +124,17 @@ pub fn normalize_relative(raw: &[u8], _policy: PathPolicy) -> Result Result { - let exact = join_under(&self.root, path).map_err(|_| VfsError::Path)?; - if exact.exists() { - return Ok(exact); - } + join_under(&self.root, path).map_err(|_| VfsError::Path)?; resolve_casefolded(&self.root, path.as_str()) } } impl Vfs for DirectoryVfs { fn metadata(&self, path: &NormalizedPath) -> Result { - let meta = fs::metadata(self.host_path(path)?).map_err(VfsError::Io)?; + let meta = fs::symlink_metadata(self.host_path(path)?).map_err(VfsError::Io)?; Ok(metadata_from_fs(&meta)) } fn read(&self, path: &NormalizedPath) -> Result, VfsError> { - let bytes = fs::read(self.host_path(path)?).map_err(VfsError::Io)?; + let host = self.host_path(path)?; + if fs::symlink_metadata(&host) + .map_err(VfsError::Io)? + .file_type() + .is_symlink() + { + return Err(VfsError::Path); + } + let bytes = fs::read(host).map_err(VfsError::Io)?; Ok(Arc::from(bytes.into_boxed_slice())) } @@ -115,7 +120,7 @@ impl Vfs for DirectoryVfs { let base = self.host_path(prefix)?; let mut entries = Vec::new(); if base.is_file() { - let metadata = fs::metadata(&base).map_err(VfsError::Io)?; + let metadata = fs::symlink_metadata(&base).map_err(VfsError::Io)?; entries.push(VfsEntry { path: prefix.clone(), metadata: metadata_from_fs(&metadata), @@ -140,6 +145,9 @@ fn resolve_casefolded(root: &Path, normalized: &str) -> Result) -> Result<() } children.sort(); for child in children { - let metadata = fs::metadata(&child).map_err(VfsError::Io)?; + let metadata = fs::symlink_metadata(&child).map_err(VfsError::Io)?; + if metadata.file_type().is_symlink() { + return Err(VfsError::Path); + } if metadata.is_dir() { list_recursive(root, &child, out)?; continue; @@ -217,21 +228,51 @@ fn metadata_from_fs(metadata: &fs::Metadata) -> VfsMetadata { #[derive(Clone, Debug, Default)] pub struct MemoryVfs { files: BTreeMap>, + lookup: BTreeMap, Vec>, } impl MemoryVfs { /// Inserts a file. #[allow(clippy::needless_pass_by_value)] pub fn insert(&mut self, path: NormalizedPath, bytes: Arc<[u8]>) { - self.files.insert(path.as_str().to_string(), bytes); + let path = path.as_str().to_string(); + self.files.insert(path, bytes); + self.rebuild_lookup(); + } + + fn rebuild_lookup(&mut self) { + self.lookup.clear(); + for path in self.files.keys() { + self.lookup + .entry(ascii_lookup_key(path.as_bytes()).0) + .or_default() + .push(path.clone()); + } + for paths in self.lookup.values_mut() { + paths.sort(); + } + } + + fn resolve_path(&self, path: &NormalizedPath) -> Result<&str, VfsError> { + let key = ascii_lookup_key(path.as_str().as_bytes()).0; + let matches = self + .lookup + .get(&key) + .ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?; + match matches.as_slice() { + [single] => Ok(single.as_str()), + [] => Err(VfsError::NotFound(path.as_str().to_string())), + _ => Err(VfsError::Ambiguous(path.as_str().to_string())), + } } } impl Vfs for MemoryVfs { fn metadata(&self, path: &NormalizedPath) -> Result { + let resolved = self.resolve_path(path)?; let bytes = self .files - .get(path.as_str()) + .get(resolved) .ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?; Ok(VfsMetadata { len: bytes.len() as u64, @@ -240,8 +281,9 @@ impl Vfs for MemoryVfs { } fn read(&self, path: &NormalizedPath) -> Result, VfsError> { + let resolved = self.resolve_path(path)?; self.files - .get(path.as_str()) + .get(resolved) .cloned() .ok_or_else(|| VfsError::NotFound(path.as_str().to_string())) } @@ -384,6 +426,36 @@ mod tests { std::fs::remove_dir_all(root).expect("cleanup"); } + #[test] + fn directory_vfs_reports_casefold_ambiguity_even_for_exact_host_path() { + let root = unique_test_dir("casefold-ambiguous"); + std::fs::create_dir_all(root.join("Data")).expect("mkdir first"); + std::fs::create_dir_all(root.join("data")).expect("mkdir second"); + std::fs::write(root.join("Data").join("File.bin"), b"first").expect("write first"); + std::fs::write(root.join("data").join("File.bin"), b"second").expect("write second"); + let collision_count = std::fs::read_dir(&root) + .expect("read root") + .flatten() + .filter(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.eq_ignore_ascii_case("data")) + }) + .count(); + if collision_count < 2 { + std::fs::remove_dir_all(root).expect("cleanup"); + return; + } + + let vfs = DirectoryVfs::new(&root); + let path = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("path"); + + assert!(matches!(vfs.read(&path), Err(VfsError::Ambiguous(_)))); + + std::fs::remove_dir_all(root).expect("cleanup"); + } + #[test] fn directory_vfs_lists_files_below_prefix() { let root = unique_test_dir("list"); @@ -403,6 +475,27 @@ mod tests { std::fs::remove_dir_all(root).expect("cleanup"); } + #[cfg(unix)] + #[test] + fn directory_vfs_rejects_symlink_escape() { + let root = unique_test_dir("symlink-escape"); + let outside = unique_test_dir("symlink-outside"); + std::fs::create_dir_all(&root).expect("mkdir root"); + std::fs::create_dir_all(&outside).expect("mkdir outside"); + std::fs::write(outside.join("secret.bin"), b"secret").expect("write outside"); + std::os::unix::fs::symlink(&outside, root.join("DATA")).expect("symlink"); + + let vfs = DirectoryVfs::new(&root); + let path = normalize_relative(b"DATA/secret.bin", PathPolicy::StrictLegacy).expect("path"); + let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix"); + + assert!(matches!(vfs.read(&path), Err(VfsError::Path))); + assert!(matches!(vfs.list(&prefix), Err(VfsError::Path))); + + std::fs::remove_dir_all(root).expect("cleanup root"); + std::fs::remove_dir_all(outside).expect("cleanup outside"); + } + #[test] fn casefold_selector_reports_ambiguous_segments() { let err = select_casefolded_match( @@ -417,7 +510,7 @@ mod tests { } #[test] - fn memory_vfs_uses_exact_lookup() { + fn memory_vfs_uses_ascii_casefold_lookup() { let path = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("path"); let mut vfs = MemoryVfs::default(); vfs.insert(path.clone(), Arc::from(b"payload".as_slice())); @@ -427,7 +520,23 @@ mod tests { let other_case = normalize_relative(b"data/file.bin", PathPolicy::StrictLegacy).expect("path"); - assert!(matches!(vfs.read(&other_case), Err(VfsError::NotFound(_)))); + assert_eq!( + vfs.read(&other_case).expect("casefold read").as_ref(), + b"payload" + ); + } + + #[test] + fn memory_vfs_reports_casefold_ambiguity() { + let first = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("first"); + let second = + normalize_relative(b"DATA/file.BIN", PathPolicy::StrictLegacy).expect("second"); + let query = normalize_relative(b"data/file.bin", PathPolicy::StrictLegacy).expect("query"); + let mut vfs = MemoryVfs::default(); + vfs.insert(first, Arc::from(b"first".as_slice())); + vfs.insert(second, Arc::from(b"second".as_slice())); + + assert!(matches!(vfs.read(&query), Err(VfsError::Ambiguous(_)))); } #[test] diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index bf63b4e..017cc9d 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -114,16 +114,23 @@ key, configuration, device profile, initial state, input/time script и верс ## Local evidence requests На текущем рабочем месте закрыты статические, corpus и headless runtime gates. -Для macOS Desktop GL подтверждены безопасный command/state trace и offscreen -pixel capture: +Для macOS Desktop GL есть только безопасный command/state trace и исторический +одноразовый offscreen pixel probe: - `cargo test -p fparkan-render-gl --offline desktop_gl33_triangle_command_capture`; - `fixtures/acceptance/macos-gl33-triangle-capture.json`. -`S3-GL-001` считается закрытым для текущей macOS-focused цели: временный -`rustc` probe создал CGL/OpenGL offscreen FBO, выполнил shader-based triangle -draw, прочитал RGBA pixels и сохранил hash capture. Probe не добавляет -project-owned `unsafe` в workspace; постоянный adapter API остаётся safe. +`S3-GL-001` не считается закрытым: временный `rustc` probe создал CGL/OpenGL +offscreen FBO, выполнил shader-based triangle draw, прочитал RGBA pixels и +сохранил hash capture, но постоянный workspace adapter по-прежнему не создаёт +SDL window, GL context, GPU resources, shader programs, draw calls или present. +Probe не добавляет project-owned `unsafe` в workspace и остаётся только external +evidence request artifact. + +Для повышения `S3-GL-001` до `covered` нужен постоянный macOS backend через +выбранную safe facade stack: SDL event/window/context lifecycle, Desktop GL 3.3 +shader/buffer/texture/draw/present path, hidden-window/offscreen smoke test и +licensed local model/terrain frame capture. Для повышения `S3-GL-002` до `covered` всё ещё нужен воспроизводимый GLES2 backend profile: GLES2 должен создать кадр, сохранить pixel capture и тот же diff --git a/fixtures/acceptance/coverage.tsv b/fixtures/acceptance/coverage.tsv index c6e8a6c..0985df6 100644 --- a/fixtures/acceptance/coverage.tsv +++ b/fixtures/acceptance/coverage.tsv @@ -21,7 +21,7 @@ S0-CORPUS-005 covered cargo test -p fparkan-corpus --offline fingerprint_changes S0-CORPUS-006 covered cargo test -p fparkan-corpus --offline atomic_report_write S0-CLI-001 covered cargo test -p fparkan-cli --offline stable_exit_codes_are_mapped S0-CLI-002 covered cargo test -p fparkan-cli --offline accepts_json_format_option archive_json_has_schema_version -S0-GL-001 covered cargo test -p fparkan-platform-sdl -p fparkan-render-gl --offline adapter_reports_safe_project_layer_ready +S0-GL-001 covered cargo test -p fparkan-platform-sdl -p fparkan-render-gl --offline adapter_boundary_is_project_owned_unsafe_free S0-LIMIT-001 covered cargo test -p fparkan-binary --offline rejects_count_stride_overflow S0-LIMIT-002 covered cargo test -p fparkan-binary --offline rejects_oversized_declared_allocation_before_read L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates @@ -220,7 +220,7 @@ S3-RENDER-006 covered cargo test -p fparkan-render --offline invalid_range_retur S3-RENDER-007 covered cargo test -p fparkan-render --offline capture_is_stable S3-RENDER-008 covered cargo test -p fparkan-render --offline recording_backend_stores_captures S3-RENDER-009 covered cargo xtask policy -S3-GL-001 covered cargo test -p fparkan-render-gl --offline desktop_gl33_triangle_command_capture plus fixtures/acceptance/macos-gl33-triangle-capture.json records macOS CGL/OpenGL offscreen FBO pixel capture +S3-GL-001 omitted permanent macOS Desktop GL 3.3 adapter is not implemented; historical CGL probe is retained as external evidence only S3-GL-002 omitted outside the current macOS-focused goal scope; GLES2 remains documented for portable/non-macOS targets S3-GL-003 covered cargo test -p fparkan-render-gl --offline shader_compile_failure_diagnostic_contains_profile_and_log S3-VIEWER-001 covered cargo test -p fparkan-viewer --offline model_fixture_uses_viewer_service_and_render_commands -- cgit v1.2.3