Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m44s
Build App (Preview) / build-linux (pull_request) Successful in 5m8s
Build App (Preview) / build-windows (pull_request) Successful in 6m28s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Build Container / build-container (pull_request) Failing after 14m49s
Three things the terminal was getting wrong. **A program that grabs the mouse and dies used to freeze the tab.** A TUI sets DECSET ?1000/?1002/?1003; if it exits without resetting them, xterm keeps routing clicks, drags and — under ?1003 — every pointer *move* to the PTY. Text selection dies and escape bytes flood the prompt. The only exit was closing the tab. `TerminalView` now reconciles a flag against `term.modes.mouseTrackingMode` in the `term.write()` callback — the mode only changes because the container printed a sequence, so one check per write catches every transition with no polling — and `Ctrl+Shift+X` or a status-bar button writes the resets back through `term.write`, never `sendInput`: the reset belongs to xterm's parser, and a still-live TUI told about it would just re-grab on its next repaint. The control is in the status bar deliberately. Mouse tracking is the *normal* state of htop, vim, lazygit and Claude Code, so a badge over the terminal would be on screen for the whole life of those programs and would swallow clicks aimed at their own top-right corner. `macOptionClickForcesSelection` is also on now: xterm's force-select is Shift everywhere except macOS, where it is Option and is gated behind that option, which defaults to false — so until now Mac users had no way to select text while a program held the mouse. **"Following" and "Jump to Current" are gone.** Claude Code draws on the alternate screen, which has no scrollback, so `viewportY` always equalled `baseY` and neither control could do anything. They did still work in bash tabs; xterm's native follow covers that, and the per-write `scrollToBottom()` went with them because it fought exactly that. What remains, on activate and after a refit, now samples `viewportY >= baseY` *before* the fit, so opening the Notes dock no longer yanks a reader to the tail. **`claude update` runs before every Claude session, not just at container start.** Containers here stop/start and often just keep running, so a long-lived one never re-checked. Both copies take the same flock: the entrypoint prints "container ready" only after its own update finishes, so opening a tab immediately would otherwise run two updaters against the same ~/.claude/bin, with `|| echo` hiding a half-written install one line before `exec claude` ran it. This turns the non-Bedrock path from a bare argv into a `bash -c` wrapper, so flags and session names are shell-interpolated now and must go through `shell_quote_arg`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0145mQi9NZiCDrznBUEEDE4n
519 lines
19 KiB
Rust
519 lines
19 KiB
Rust
use tauri::{AppHandle, Emitter, State};
|
|
|
|
use crate::commands::aws_commands;
|
|
use crate::models::{Backend, BedrockAuthMethod, Project};
|
|
use crate::AppState;
|
|
|
|
/// Build the command to run in the container terminal.
|
|
///
|
|
/// Always a `bash -c` script, because every session runs [`UPDATE_PRELUDE`]
|
|
/// before `exec claude`. For Bedrock Profile projects the script additionally
|
|
/// validates the AWS session first, and runs `aws sso login` if it has expired
|
|
/// so the user can re-authenticate (the URL is clickable via xterm.js
|
|
/// WebLinksAddon).
|
|
fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec<String> {
|
|
let settings = state.settings_store.get();
|
|
build_claude_terminal_cmd(
|
|
project,
|
|
settings.global_aws.aws_profile.as_deref(),
|
|
session_name,
|
|
)
|
|
}
|
|
|
|
/// Shell line run immediately before `exec claude` in every Claude terminal
|
|
/// session.
|
|
///
|
|
/// `container/entrypoint.sh` already runs `claude update` when the container
|
|
/// starts, but containers here use a stop/start (and often just keep running)
|
|
/// model, so a long-lived container's CLI goes stale between restarts. Running
|
|
/// it per session is what keeps a week-old container current.
|
|
///
|
|
/// Deliberately non-fatal and time-bounded: `|| echo` swallows a failure (no
|
|
/// network, npm registry down) so a session always opens, and `timeout 60`
|
|
/// bounds how long a user waits for a terminal.
|
|
///
|
|
/// **`flock` is load-bearing, not tidiness.** Nothing serialises this against
|
|
/// the entrypoint's own `claude update`, and the entrypoint prints "container
|
|
/// ready" only *after* its copy finishes — so "start the project, open a tab"
|
|
/// races two updaters against the same `~/.claude/bin` install, as does
|
|
/// opening two tabs at once. `|| echo` would then hide a half-written install
|
|
/// behind a friendly message and the very next line (`exec claude`) would run
|
|
/// it. `-w 90` gives the entrypoint's `timeout 120` copy room to finish rather
|
|
/// than failing the wait, and `-E 0` makes losing the race a success: the
|
|
/// other holder just updated, so there is nothing left to do.
|
|
pub(crate) const UPDATE_PRELUDE: &str = concat!(
|
|
"flock -w 90 -E 0 /tmp/.triple-c-claude-update.lock ",
|
|
r#"timeout 60 claude update 2>&1 || echo "(update skipped — continuing)""#,
|
|
);
|
|
|
|
/// Single-quote one argument for interpolation into a shell script string.
|
|
fn shell_quote_arg(arg: &str) -> String {
|
|
format!(" '{}'", arg.replace('\'', "'\\''"))
|
|
}
|
|
|
|
/// The testable core of [`build_terminal_cmd`], taking the resolved global AWS
|
|
/// profile rather than the whole [`AppState`].
|
|
fn build_claude_terminal_cmd(
|
|
project: &Project,
|
|
global_aws_profile: Option<&str>,
|
|
session_name: Option<&str>,
|
|
) -> Vec<String> {
|
|
let is_bedrock_profile = project.backend == Backend::Bedrock
|
|
&& project
|
|
.bedrock_config
|
|
.as_ref()
|
|
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
|
|
.unwrap_or(false);
|
|
|
|
let permission_args = project.effective_permission_mode().cli_args();
|
|
|
|
// The args are interpolated into a shell script string, so single-quote
|
|
// each one.
|
|
let name_flag = session_name
|
|
.filter(|n| !n.is_empty())
|
|
.map(|n| format!(" -n{}", shell_quote_arg(n)))
|
|
.unwrap_or_default();
|
|
let permission_flags: String = permission_args.iter().map(|a| shell_quote_arg(a)).collect();
|
|
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
|
|
|
|
if !is_bedrock_profile {
|
|
return vec![
|
|
"bash".to_string(),
|
|
"-c".to_string(),
|
|
format!("{}\n{}\n", UPDATE_PRELUDE, claude_cmd),
|
|
];
|
|
}
|
|
|
|
let profile = aws_commands::resolve_profile_for_project(project, global_aws_profile);
|
|
|
|
// Build a bash wrapper that validates credentials, re-auths if needed,
|
|
// then exec's into claude.
|
|
|
|
let script = format!(
|
|
r#"
|
|
echo "Validating AWS session for profile '{profile}'..."
|
|
if aws sts get-caller-identity --profile '{profile}' >/dev/null 2>&1; then
|
|
echo "AWS session valid."
|
|
else
|
|
echo "AWS session expired or invalid."
|
|
# Check if this profile uses SSO (has sso_start_url or sso_session configured)
|
|
if aws configure get sso_start_url --profile '{profile}' >/dev/null 2>&1 || \
|
|
aws configure get sso_session --profile '{profile}' >/dev/null 2>&1; then
|
|
echo "Starting SSO login..."
|
|
echo ""
|
|
triple-c-sso-refresh
|
|
if [ $? -ne 0 ]; then
|
|
echo ""
|
|
echo "SSO login failed or was cancelled. Starting Claude anyway..."
|
|
echo "You may see authentication errors."
|
|
echo ""
|
|
fi
|
|
else
|
|
echo "Profile '{profile}' does not use SSO. Check your AWS credentials."
|
|
echo "Starting Claude anyway..."
|
|
echo ""
|
|
fi
|
|
fi
|
|
{update_prelude}
|
|
{claude_cmd}
|
|
"#,
|
|
profile = profile,
|
|
update_prelude = UPDATE_PRELUDE,
|
|
claude_cmd = claude_cmd
|
|
);
|
|
|
|
vec![
|
|
"bash".to_string(),
|
|
"-c".to_string(),
|
|
script,
|
|
]
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn open_terminal_session(
|
|
project_id: String,
|
|
session_id: String,
|
|
session_type: Option<String>,
|
|
session_name: Option<String>,
|
|
app_handle: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) -> Result<(), String> {
|
|
let project = state
|
|
.projects_store
|
|
.get(&project_id)
|
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
|
|
|
let container_id = project
|
|
.container_id
|
|
.as_ref()
|
|
.ok_or_else(|| "Container not running".to_string())?;
|
|
|
|
let cmd = match session_type.as_deref() {
|
|
Some("bash") => vec!["bash".to_string(), "-l".to_string()],
|
|
_ => build_terminal_cmd(&project, &state, session_name.as_deref()),
|
|
};
|
|
|
|
let output_event = format!("terminal-output-{}", session_id);
|
|
let exit_event = format!("terminal-exit-{}", session_id);
|
|
let app_handle_output = app_handle.clone();
|
|
let app_handle_exit = app_handle.clone();
|
|
|
|
state
|
|
.exec_manager
|
|
.create_session(
|
|
container_id,
|
|
&session_id,
|
|
cmd,
|
|
move |data| {
|
|
let _ = app_handle_output.emit(&output_event, data);
|
|
},
|
|
Box::new(move || {
|
|
let _ = app_handle_exit.emit(&exit_event, ());
|
|
}),
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn terminal_input(
|
|
session_id: String,
|
|
data: Vec<u8>,
|
|
state: State<'_, AppState>,
|
|
) -> Result<(), String> {
|
|
state.exec_manager.send_input(&session_id, data).await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn terminal_resize(
|
|
session_id: String,
|
|
cols: u16,
|
|
rows: u16,
|
|
state: State<'_, AppState>,
|
|
) -> Result<(), String> {
|
|
state.exec_manager.resize(&session_id, cols, rows).await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn close_terminal_session(
|
|
session_id: String,
|
|
state: State<'_, AppState>,
|
|
) -> Result<(), String> {
|
|
// Close audio bridge if it exists
|
|
let audio_session_id = format!("audio-{}", session_id);
|
|
state.exec_manager.close_session(&audio_session_id).await;
|
|
// Close terminal session
|
|
state.exec_manager.close_session(&session_id).await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn paste_image_to_terminal(
|
|
session_id: String,
|
|
image_data: Vec<u8>,
|
|
state: State<'_, AppState>,
|
|
) -> Result<String, String> {
|
|
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
|
|
|
let timestamp = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis();
|
|
let file_name = format!("clipboard_{}.png", timestamp);
|
|
|
|
state
|
|
.exec_manager
|
|
.write_file_to_container(&container_id, &file_name, &image_data)
|
|
.await
|
|
}
|
|
|
|
/// Copy a host file (e.g. dragged onto the terminal) into the container so
|
|
/// Claude Code can read it, and return the in-container path. Mirrors the
|
|
/// image-paste flow: the file is placed under /tmp/triple-c-drops/ keeping its
|
|
/// original name. Returns an error for paths that aren't readable regular files
|
|
/// (e.g. a dropped directory).
|
|
#[tauri::command]
|
|
pub async fn upload_host_file_to_terminal(
|
|
session_id: String,
|
|
host_path: String,
|
|
state: State<'_, AppState>,
|
|
) -> Result<String, String> {
|
|
// The drop target is a host path chosen by the webview, not by the OS drag
|
|
// itself, so it goes through `file_commands`' host-read policy: absolute,
|
|
// no traversal, and nothing whose path passes through a hidden directory
|
|
// (`~/.ssh`, `~/.aws`, `~/.local/bin`) or a system location — applied to
|
|
// the path with its symlinks already resolved, so a visible directory that
|
|
// *leads* to one of those is refused too. What comes back is that resolved
|
|
// path, and it is what gets opened. Four commands touch a host path now,
|
|
// but only two take it *over IPC*: this one and `download_container_backup`.
|
|
// The Files pane's `download_container_file` and `upload_files_to_container`
|
|
// open their dialog from Rust instead, so for them the policy above is
|
|
// defence in depth and for these two it is the boundary itself.
|
|
// The name is taken from the path the user actually dropped, *before*
|
|
// resolution. Deriving it from the resolved path renames the file behind
|
|
// the user's back: dropping `~/Downloads/latest.log`, where `latest.log` is
|
|
// a symlink, would land it in the container as `2026-08-23.log`.
|
|
let base = crate::commands::file_commands::host_upload_name(&host_path)?;
|
|
let host_path = crate::commands::file_commands::resolve_host_read_path(&host_path).await?;
|
|
|
|
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
|
|
|
let meta = tokio::fs::metadata(&host_path)
|
|
.await
|
|
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
|
|
// `!is_file()`, not `!is_dir()`. A FIFO is neither a directory nor a
|
|
// regular file, reports `len() == 0`, and passes both the directory check
|
|
// and the size cap below — and `std::fs::File::open` on one blocks forever
|
|
// with no writer, with no timeout anywhere on this path. The upload then
|
|
// never returns, the toast sticks on "Adding N files…" for the session and
|
|
// the rest of the batch is abandoned. Sockets and device nodes are the same
|
|
// shape. This is one of two routes for getting a host file into a
|
|
// container (the Files pane's upload is the other), so it is the wrong
|
|
// place to be clever.
|
|
if !meta.is_file() {
|
|
return Err(if meta.is_dir() {
|
|
format!("{} is a directory — drop individual files", host_path)
|
|
} else {
|
|
format!(
|
|
"{} is not a regular file — only ordinary files can be dropped into a terminal",
|
|
host_path
|
|
)
|
|
});
|
|
}
|
|
|
|
// Guard against ballooning host RAM: the file is packed into an in-memory
|
|
// tar before upload, so cap the size of a dropped file. The ceiling lives
|
|
// with the code that does the reading, which re-applies it to the open
|
|
// descriptor — this check is here only so the refusal reads like a sentence
|
|
// instead of arriving after a 300 MB read.
|
|
use crate::docker::exec::MAX_DROP_BYTES;
|
|
if meta.len() > MAX_DROP_BYTES {
|
|
return Err(format!(
|
|
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project instead.",
|
|
meta.len() as f64 / (1024.0 * 1024.0),
|
|
MAX_DROP_BYTES / (1024 * 1024)
|
|
));
|
|
}
|
|
|
|
|
|
|
|
// Ensure the destination directory exists rather than relying on Docker's
|
|
// archive extractor to create the parent for the uploaded tar entry.
|
|
crate::docker::exec::exec_oneshot(
|
|
&container_id,
|
|
vec!["mkdir".to_string(), "-p".to_string(), "/tmp/triple-c-drops".to_string()],
|
|
)
|
|
.await?;
|
|
|
|
let file_name = format!("triple-c-drops/{}", base);
|
|
crate::docker::exec::upload_host_file_to_container(
|
|
&container_id,
|
|
&host_path,
|
|
"/tmp",
|
|
&file_name,
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn start_audio_bridge(
|
|
session_id: String,
|
|
state: State<'_, AppState>,
|
|
) -> Result<(), String> {
|
|
// Get container_id from the terminal session
|
|
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
|
|
|
// Create audio bridge exec session with ID "audio-{session_id}"
|
|
// The loop handles reconnection when the FIFO reader (fake rec) is killed and restarted
|
|
let audio_session_id = format!("audio-{}", session_id);
|
|
let cmd = vec![
|
|
"bash".to_string(),
|
|
"-c".to_string(),
|
|
"FIFO=/tmp/triple-c-audio-input; [ -p \"$FIFO\" ] || mkfifo \"$FIFO\"; trap '' PIPE; while true; do cat > \"$FIFO\" 2>/dev/null; sleep 0.1; done".to_string(),
|
|
];
|
|
|
|
state
|
|
.exec_manager
|
|
.create_session_with_tty(
|
|
&container_id,
|
|
&audio_session_id,
|
|
cmd,
|
|
false,
|
|
|_data| { /* ignore output from the audio bridge */ },
|
|
Box::new(|| { /* no exit handler needed */ }),
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn send_audio_data(
|
|
session_id: String,
|
|
data: Vec<u8>,
|
|
state: State<'_, AppState>,
|
|
) -> Result<(), String> {
|
|
let audio_session_id = format!("audio-{}", session_id);
|
|
state.exec_manager.send_input(&audio_session_id, data).await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn stop_audio_bridge(
|
|
session_id: String,
|
|
state: State<'_, AppState>,
|
|
) -> Result<(), String> {
|
|
let audio_session_id = format!("audio-{}", session_id);
|
|
state.exec_manager.close_session(&audio_session_id).await;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{build_claude_terminal_cmd, UPDATE_PRELUDE};
|
|
use crate::models::Project;
|
|
|
|
/// A dropped file must be named the way the *user* named it.
|
|
///
|
|
/// The bug this pins: `upload_host_file_to_terminal` derived the tar entry
|
|
/// name from the path *after* symlink resolution, so dropping
|
|
/// `~/Downloads/latest.log` — where `latest.log` is a symlink to
|
|
/// `2026-08-23.log` — silently landed the file in the container under the
|
|
/// target's name. Nothing errored; the user just got a name they never
|
|
/// typed.
|
|
///
|
|
/// This asserts the shared helper's contract from the terminal side: the
|
|
/// answer comes from the spelling, and a path that does not name a file is
|
|
/// refused rather than silently substituted (it used to fall back to
|
|
/// `"dropped-file"`).
|
|
/// A `Project` with only the fields these tests care about set; the rest
|
|
/// come through serde so the test does not have to track every field.
|
|
fn project(backend: &str, bedrock_config: serde_json::Value) -> Project {
|
|
serde_json::from_value(serde_json::json!({
|
|
"id": "p1",
|
|
"name": "Test",
|
|
"paths": [],
|
|
"container_id": null,
|
|
"status": "running",
|
|
"backend": backend,
|
|
"bedrock_config": bedrock_config,
|
|
"ollama_config": null,
|
|
"openai_compatible_config": null,
|
|
"allow_docker_access": false,
|
|
"full_permissions": false,
|
|
"ssh_key_path": null,
|
|
"git_user_name": null,
|
|
"git_user_email": null,
|
|
"created_at": "now",
|
|
"updated_at": "now"
|
|
}))
|
|
.expect("test project deserializes")
|
|
}
|
|
|
|
/// Every Claude session updates the CLI before launching it.
|
|
///
|
|
/// `container/entrypoint.sh` only updates at container *start*, and these
|
|
/// containers are long-lived, so a stale CLI is the normal case without
|
|
/// this. The plain (non-Bedrock) path therefore has to be a `bash -c`
|
|
/// wrapper rather than a bare `claude` argv.
|
|
#[test]
|
|
fn build_terminal_cmd_updates_before_launching_claude() {
|
|
let cmd = build_claude_terminal_cmd(&project("anthropic", serde_json::Value::Null), None, None);
|
|
|
|
assert_eq!(cmd[0], "bash");
|
|
assert_eq!(cmd[1], "-c");
|
|
assert!(
|
|
cmd[2].contains(UPDATE_PRELUDE),
|
|
"plain path must run the update prelude: {}",
|
|
cmd[2]
|
|
);
|
|
assert!(cmd[2].contains("exec claude"), "got: {}", cmd[2]);
|
|
// The update has to happen *before* the exec, which never returns.
|
|
assert!(
|
|
cmd[2].find(UPDATE_PRELUDE).unwrap() < cmd[2].find("exec claude").unwrap(),
|
|
"prelude must precede the exec: {}",
|
|
cmd[2]
|
|
);
|
|
assert!(
|
|
UPDATE_PRELUDE.contains("timeout 60") && UPDATE_PRELUDE.contains("||"),
|
|
"the update must stay time-bounded and non-fatal"
|
|
);
|
|
}
|
|
|
|
/// The session name is interpolated into a shell script, so a quote in it
|
|
/// must not break out of its single-quoted argument.
|
|
#[test]
|
|
fn build_terminal_cmd_escapes_a_quoted_session_name() {
|
|
let cmd = build_claude_terminal_cmd(
|
|
&project("anthropic", serde_json::Value::Null),
|
|
None,
|
|
Some("Bob's tab; rm -rf /"),
|
|
);
|
|
|
|
assert!(
|
|
cmd[2].contains(r#"exec claude -n 'Bob'\''s tab; rm -rf /'"#),
|
|
"session name must be single-quote escaped: {}",
|
|
cmd[2]
|
|
);
|
|
}
|
|
|
|
/// Permission flags travel the same escaped path, and an empty name adds
|
|
/// no `-n` at all.
|
|
#[test]
|
|
fn build_terminal_cmd_quotes_permission_flags_and_omits_an_empty_name() {
|
|
let mut p = project("anthropic", serde_json::Value::Null);
|
|
p.full_permissions = true;
|
|
let cmd = build_claude_terminal_cmd(&p, None, Some(""));
|
|
|
|
assert!(
|
|
cmd[2].contains("exec claude '--dangerously-skip-permissions'\n"),
|
|
"got: {}",
|
|
cmd[2]
|
|
);
|
|
assert!(!cmd[2].contains(" -n "), "empty name must add no flag: {}", cmd[2]);
|
|
}
|
|
|
|
/// The Bedrock-profile path keeps its AWS validation *and* gains the
|
|
/// prelude, immediately before the exec.
|
|
#[test]
|
|
fn build_terminal_cmd_bedrock_validates_aws_and_updates() {
|
|
let cmd = build_claude_terminal_cmd(
|
|
&project("bedrock", serde_json::json!({
|
|
"auth_method": "profile",
|
|
"aws_region": "us-east-1",
|
|
"aws_profile": "acme",
|
|
"model_id": null,
|
|
"disable_prompt_caching": false
|
|
})),
|
|
None,
|
|
Some("it's fine"),
|
|
);
|
|
|
|
assert_eq!(cmd[0], "bash");
|
|
let script = &cmd[2];
|
|
assert!(script.contains("aws sts get-caller-identity --profile 'acme'"), "got: {}", script);
|
|
assert!(script.contains("triple-c-sso-refresh"), "got: {}", script);
|
|
assert!(script.contains(UPDATE_PRELUDE), "got: {}", script);
|
|
assert!(script.contains(r#"exec claude -n 'it'\''s fine'"#), "got: {}", script);
|
|
assert!(
|
|
script.find(UPDATE_PRELUDE).unwrap() < script.find("exec claude").unwrap(),
|
|
"prelude must precede the exec: {}",
|
|
script
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_dropped_file_keeps_the_name_the_user_dropped() {
|
|
use crate::commands::file_commands::host_upload_name;
|
|
|
|
assert_eq!(
|
|
host_upload_name("/home/u/Downloads/latest.log").unwrap(),
|
|
"latest.log"
|
|
);
|
|
assert!(
|
|
host_upload_name("/home/u/Downloads/").is_err(),
|
|
"a directory is not a file to drop"
|
|
);
|
|
assert!(
|
|
host_upload_name("/home/u/..").is_err(),
|
|
"the name becomes a tar entry, a container path and an argv element"
|
|
);
|
|
}
|
|
}
|