use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::thread; use std::time::{Duration, Instant}; use tempfile::tempdir; use wip_fs_server::{AccessMode, ServerConfig, WipFsServer}; use wip_tui::app::{Focus, HistoryKind, InputKey, Modal, ViewState}; use wip_tui::config::Config; use wip_tui::runtime::{HttpTransport, Runtime, default_wire_limits}; #[test] fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() { let temporary = tempdir().expect("temporary root"); let outside = tempdir().expect("outside root"); std::fs::write(temporary.path().join("hello.txt"), b"hello world").expect("write file"); std::fs::create_dir(temporary.path().join("nested")).expect("create nested directory"); std::fs::write(outside.path().join("secret.txt"), b"outside-secret").expect("outside file"); #[cfg(unix)] std::os::unix::fs::symlink(outside.path(), temporary.path().join("outside-link")) .expect("create symlink"); let mut server_config = ServerConfig::for_root(temporary.path()); server_config.access = AccessMode::ReadWrite; let bound = Arc::new( WipFsServer::new(server_config) .expect("build filesystem server") .bind() .expect("bind filesystem server"), ); let shutdown = Arc::new(AtomicBool::new(false)); let worker = { let bound = Arc::clone(&bound); let shutdown = Arc::clone(&shutdown); thread::spawn(move || bound.serve_until(shutdown)) }; let endpoint = format!("http://127.0.0.1:{}/wip", bound.local_addr().port()); let config = Config::parse([endpoint], None, Some("filesystem-test".into()), |_| None) .expect("development config"); let transport = HttpTransport::new(&config, default_wire_limits().max_response_bytes()) .expect("HTTP transport"); let mut runtime = Runtime::new(&config, transport).expect("runtime"); runtime.connect(); settle(&mut runtime); assert_eq!(runtime.app().selected_path, "/"); assert!(runtime.app().entries.contains_key("/hello.txt")); assert!(runtime.app().entries.contains_key("/nested")); assert!(runtime.app().entries.contains_key("/outside-link")); let empty_directory = &runtime.app().entries["/nested"]; assert_eq!(empty_directory.tree_state, ViewState::Fresh); assert!(empty_directory.children.is_empty()); assert!(!empty_directory.expanded); // Visible Objects have their interface descriptors prefetched before selection. let file_interfaces = runtime.app().entries["/hello.txt"] .object .as_ref() .expect("file object") .interfaces .clone(); assert!(!file_interfaces.is_empty()); for reference in &file_interfaces { assert_eq!(runtime.app().interfaces[reference].state, ViewState::Fresh); assert!(runtime.app().interfaces[reference].descriptor.is_some()); } let read_reference = file_interfaces[0].clone(); assert_eq!( runtime.app().interfaces[&read_reference] .descriptor .as_ref() .expect("read descriptor") .operations[0] .name, "read" ); // Keyboard-select the already prefetched read operation. runtime.app_mut().selected_path = "/hello.txt".into(); runtime.app_mut().focus = Focus::Operations; runtime.app_mut().operation_cursor = 1; assert!(!runtime.handle_key(InputKey::Enter)); // Descriptor-driven read editor, explicit confirmation, and execution. assert!(matches!(runtime.app().modal, Modal::Editor(_))); runtime.handle_key(InputKey::Enter); assert!(matches!(runtime.app().modal, Modal::Confirm { .. })); runtime.handle_key(InputKey::Enter); settle(&mut runtime); assert_eq!(runtime.app().history[0].kind, HistoryKind::Success); assert!( runtime.app().history[0] .detail .contains("68656c6c6f20776f726c64") ); assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Fresh); runtime.app_mut().focus = Focus::Operations; // Use the separately-qualified prefetched write interface and replace with the // automatically attached observed validator. runtime.app_mut().operation_cursor = 3; runtime.handle_key(InputKey::Enter); set_only_field(&mut runtime, "dXBkYXRlZA=="); // "updated" confirm_and_execute(&mut runtime); settle(&mut runtime); assert_eq!( std::fs::read(temporary.path().join("hello.txt")).expect("updated file"), b"updated" ); assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Fresh); // Prepare another replacement using the post-operation validator, then mutate // the file externally before explicit execution. The stale validator is rejected, // the Entry becomes stale, and no automatic call is created. runtime.app_mut().focus = Focus::Operations; runtime.app_mut().operation_cursor = 3; runtime.handle_key(InputKey::Enter); set_only_field(&mut runtime, "c3RhbGUtd3JpdGU="); runtime.handle_key(InputKey::Enter); // confirmation fixes user input only std::fs::write(temporary.path().join("hello.txt"), b"external").expect("external change"); let history_before = runtime.app().history.len(); runtime.handle_key(InputKey::Enter); settle(&mut runtime); assert_eq!( std::fs::read(temporary.path().join("hello.txt")).expect("externally changed file"), b"external" ); assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Stale); assert!(runtime.app().in_flight.is_empty()); assert_eq!(runtime.app().history.len(), history_before + 1); assert_eq!(runtime.app().history[0].kind, HistoryKind::DomainFailure); assert!( runtime.app().history[0] .detail .contains("no automatic retry") ); // Refresh enables a new, separately confirmed execution. runtime.app_mut().focus = Focus::Tree; runtime.handle_key(InputKey::Refresh); settle(&mut runtime); runtime.app_mut().focus = Focus::Operations; runtime.app_mut().operation_cursor = 3; runtime.handle_key(InputKey::Enter); set_only_field(&mut runtime, "ZmluYWw="); confirm_and_execute(&mut runtime); settle(&mut runtime); assert_eq!( std::fs::read(temporary.path().join("hello.txt")).expect("final file"), b"final" ); // Symlink inspection exposes the link payload only. It never traverses or // materializes the target directory outside the configured root. #[cfg(unix)] { runtime.app_mut().selected_path = "/outside-link".into(); runtime.app_mut().focus = Focus::Operations; runtime.app_mut().operation_cursor = 0; runtime.handle_key(InputKey::Enter); settle(&mut runtime); runtime.app_mut().operation_cursor = 1; runtime.handle_key(InputKey::Enter); runtime.handle_key(InputKey::Enter); runtime.handle_key(InputKey::Enter); settle(&mut runtime); assert_eq!(runtime.app().history[0].kind, HistoryKind::Success); assert!(!runtime.app().history[0].detail.contains("outside-secret")); assert!( !runtime .app() .entries .contains_key("/outside-link/secret.txt") ); } shutdown.store(true, Ordering::Release); worker .join() .expect("join filesystem listener") .expect("filesystem listener exits"); } fn settle(runtime: &mut Runtime) { let deadline = Instant::now() + Duration::from_secs(5); while !runtime.app().in_flight.is_empty() { assert!(Instant::now() < deadline, "runtime requests did not settle"); runtime.wait_and_poll(Duration::from_millis(20)); } } fn set_only_field(runtime: &mut Runtime, input: &str) { let Modal::Editor(editor) = &mut runtime.app_mut().modal else { panic!("operation editor was not open") }; assert_eq!(editor.fields.len(), 1); editor.fields[0].input = input.into(); } fn confirm_and_execute(runtime: &mut Runtime) { runtime.handle_key(InputKey::Enter); assert!(matches!(runtime.app().modal, Modal::Confirm { .. })); runtime.handle_key(InputKey::Enter); }