206 lines
8.1 KiB
Rust
206 lines
8.1 KiB
Rust
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<HttpTransport>) {
|
|
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<HttpTransport>, 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<HttpTransport>) {
|
|
runtime.handle_key(InputKey::Enter);
|
|
assert!(matches!(runtime.app().modal, Modal::Confirm { .. }));
|
|
runtime.handle_key(InputKey::Enter);
|
|
}
|