aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-22 15:12:57 +0300
committerValentin Popov <valentin@popov.link>2026-06-22 15:12:57 +0300
commitf69c893a401730339ad72610c573e20282573045 (patch)
tree8db67167f966463f979aa5417714ed23b333c3d1
parent543672796161e1ab500ed446611d391a451add09 (diff)
downloadfparkan-f69c893a401730339ad72610c573e20282573045.tar.xz
fparkan-f69c893a401730339ad72610c573e20282573045.zip
fix: harden path lookup and mark gl backend gap
-rw-r--r--adapters/fparkan-platform-sdl/src/lib.rs34
-rw-r--r--adapters/fparkan-render-gl/src/lib.rs12
-rw-r--r--adr/ADR-0007-safe-sdl-opengl.md23
-rw-r--r--crates/fparkan-path/src/lib.rs27
-rw-r--r--crates/fparkan-vfs/src/lib.rs137
-rw-r--r--docs/appendices/knowledge-boundaries.md19
-rw-r--r--fixtures/acceptance/coverage.tsv4
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<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,
});
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<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() {
@@ -224,6 +230,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)
.expect("relative path");
diff --git a/crates/fparkan-vfs/src/lib.rs b/crates/fparkan-vfs/src/lib.rs
index dd71670..2545ecf 100644
--- a/crates/fparkan-vfs/src/lib.rs
+++ b/crates/fparkan-vfs/src/lib.rs
@@ -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()))
}
@@ -385,6 +427,36 @@ mod tests {
}
#[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");
std::fs::create_dir_all(root.join("DATA").join("MAPS")).expect("mkdir");
@@ -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