fix: harden path lookup and mark gl backend gap

This commit is contained in:
2026-06-22 16:12:57 +04:00
parent 5436727961
commit f69c893a40
7 changed files with 209 additions and 47 deletions
+17 -17
View File
@@ -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<PlatformEvent>,
}
impl SdlEventSourceProof {
impl SdlEventSourceStub {
/// Creates an event source with deterministic pending events.
#[must_use]
pub fn new(pending: Vec<PlatformEvent>) -> Self {
@@ -54,22 +54,22 @@ impl SdlEventSourceProof {
}
}
impl EventSource for SdlEventSourceProof {
impl EventSource for SdlEventSourceStub {
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> 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,
});
+6 -6
View File
@@ -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);
}
+22 -1
View File
@@ -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.
+26 -1
View File
@@ -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<NormalizedPath, PathError> {
pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPath, PathError> {
if raw.is_empty() {
return Err(PathError::Empty);
}
@@ -124,11 +124,17 @@ pub fn normalize_relative(raw: &[u8], _policy: PathPolicy) -> Result<NormalizedP
let mut parts = Vec::new();
for part in text.split(['/', '\\']) {
if part.is_empty() || part == "." {
if policy == PathPolicy::StrictLegacy {
return Err(PathError::ParentTraversal);
}
continue;
}
if part == ".." {
return Err(PathError::ParentTraversal);
}
if policy == PathPolicy::StrictLegacy && part.contains(':') {
return Err(PathError::Absolute);
}
parts.push(part);
}
if parts.is_empty() {
@@ -223,6 +229,25 @@ mod tests {
);
}
#[test]
fn strict_legacy_rejects_host_only_segments() {
assert_eq!(
normalize_relative(b"./DATA/MAPS", PathPolicy::StrictLegacy),
Err(PathError::ParentTraversal)
);
assert_eq!(
normalize_relative(b"DATA//MAPS", PathPolicy::StrictLegacy),
Err(PathError::ParentTraversal)
);
assert_eq!(
normalize_relative(b"DATA/stream:name", PathPolicy::StrictLegacy),
Err(PathError::Absolute)
);
let host = normalize_relative(b"./DATA//MAPS", PathPolicy::HostCompatible).expect("host");
assert_eq!(host.as_str(), "DATA/MAPS");
}
#[test]
fn join_under_keeps_normalized_path_below_root() {
let rel = normalize_relative(b"DATA/MAPS/Land.map", PathPolicy::StrictLegacy)
+123 -14
View File
@@ -1,7 +1,7 @@
#![forbid(unsafe_code)]
//! Virtual filesystem ports for resource loading.
use fparkan_path::{join_under, NormalizedPath};
use fparkan_path::{ascii_lookup_key, join_under, NormalizedPath};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
@@ -92,22 +92,27 @@ impl DirectoryVfs {
}
fn host_path(&self, path: &NormalizedPath) -> Result<PathBuf, VfsError> {
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<VfsMetadata, VfsError> {
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<Arc<[u8]>, 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<PathBuf, VfsError
continue;
};
if name.eq_ignore_ascii_case(segment) {
if entry.file_type().map_err(VfsError::Io)?.is_symlink() {
return Err(VfsError::Path);
}
matches.push(entry.path());
}
}
@@ -175,7 +183,10 @@ fn list_recursive(root: &Path, dir: &Path, out: &mut Vec<VfsEntry>) -> 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<String, Arc<[u8]>>,
lookup: BTreeMap<Vec<u8>, Vec<String>>,
}
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<VfsMetadata, VfsError> {
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<Arc<[u8]>, 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]
+13 -6
View File
@@ -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 и тот же
+2 -2
View File
@@ -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
1 # Acceptance coverage manifest.
21 S0-CORPUS-006
22 S0-CLI-001
23 S0-CLI-002
24 S0-GL-001
25 S0-LIMIT-001
26 S0-LIMIT-002
27 L1-P1-NRES-001
220 S3-RENDER-007
221 S3-RENDER-008
222 S3-RENDER-009
223 S3-GL-001
224 S3-GL-002
225 S3-GL-003
226 S3-VIEWER-001