Hold back the Disk panel and OS drag-out from the ship branch
This is a scope reduction, not an abandonment. Both subsystems are
preserved in full on `hold/disk-and-dragout` and are intended to come
back once they have been hardened separately. Nothing here is a
judgement that the features are unwanted — three successive
audit-and-fix cycles each closed a critical defect in these two areas
and each opened a new one, so the rest of the round ships now and these
two get their own cycle rather than holding it up.
Removed: the Disk settings panel and its whole reclaim / destroy /
compaction surface — `DiskSettings`, `DiskProjectTable`, `useDiskUsage`,
`docker/disk.rs`, `disk_tests.rs`, the disk commands in
`docker_commands.rs`, and their `generate_handler!` entries. Dropping
the IPC entries is the point: a UI-only removal would have left five
commands callable by a compromised webview, one of them a verified
arbitrary-DELETE primitive. `sweep_orphaned_snapshots`'s *command* goes
with them (the panel was its only caller); the sweep itself stays.
Removed: OS drag-out from the Files tab — `stage_container_file_for_drag`
and its host staging lifecycle, the pointer gesture and `dragPreview`,
`stageForDrag` / `isStagedHostPath`, the `tauri-plugin-drag` and
`@crabnebula/tauri-plugin-drag` dependencies, and the
`drag:allow-start-drag` capability grant, which could not be scoped.
The capability test's expected list is updated; its `*:default` and
`store:*` assertions are untouched.
Kept, deliberately: drag-and-drop *into* the app (Files pane and
terminal) and "Save to host…", which is now the only route out of a
container. The prevention work is untouched — the pre-commit scrub and
`SNAPSHOT_SCRUB_PATHS`, capped container logs, the `triple-c.base` /
`triple-c.managed` labels, `sweep_orphaned_snapshots` and the startup
housekeeping, the migration pin/probe reapers, scheduler log pruning,
`formatBytes.ts`, and `project_lock.rs` in full with every acquisition
site outside `disk.rs`.
Entanglements, resolved rather than deleted blind:
* `container.rs`'s `a_compaction_runs_this_module_s_scrub_script_byte_for_byte`
pinned the compaction Dockerfile against `snapshot_scrub_script()`.
Dropped — it existed only for compaction. `snapshot_scrub_script` and
its containment tests are untouched.
* `lib.rs`'s startup reap of `:compacting` tags and `triple-c-compact-*`
containers is dropped: nothing on this branch creates them.
* `project_lock`'s `Compaction` / `CacheClear` variants and
`any_held_excluding`, `migration_commands::is_migrating`, and
`formatBytes{Delta,Ceiling}` lose their last production caller but are
kept and still tested, annotated with why.
* `projects_store::corrupt_since` and `migration_store::peek_ownerless_since`
were read only by the disk survey and are removed. The corrupt-load
marker and `.bak` are still written.
Verified: `npm run test` 611 passing, `npx tsc --noEmit` clean,
`npm run build` green; `cargo test` 419 passed / 2 ignored,
`cargo build` 0 warnings. Every test removed belongs to a removed
feature — no kept-behaviour test was weakened or deleted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::{Duration, SystemTime};
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use base64::Engine as _;
|
||||
@@ -9,7 +8,7 @@ use bollard::container::{DownloadFromContainerOptions, LogOutput, UploadToContai
|
||||
use bollard::exec::{CreateExecOptions, StartExecResults};
|
||||
use futures_util::StreamExt;
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use tauri::State;
|
||||
|
||||
use crate::docker::client::get_docker;
|
||||
use crate::docker::exec::{
|
||||
@@ -1271,241 +1270,6 @@ pub async fn read_container_file(
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Drag-out staging
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Dragging a file onto the host desktop hands the OS a *host* path, and the
|
||||
// files in this panel live inside a container, where nothing on the desktop can
|
||||
// reach them. So a drag-out is really a copy-then-drag: materialise the file
|
||||
// into a host temp directory first, then start the native drag on that copy.
|
||||
//
|
||||
// The copy is the reason this section carries a lifecycle. A staging directory
|
||||
// nobody empties is a disk leak with a gesture attached to it, so there are two
|
||||
// halves and both matter: `clear_drag_staging` on exit, and
|
||||
// `reap_drag_staging` at startup for whatever a crash left behind.
|
||||
|
||||
/// Ceiling on one staged copy. Deliberately the same 256 MiB as
|
||||
/// [`MAX_UPLOAD_BYTES`] — it is the same whole-file-through-host-RAM round trip,
|
||||
/// only in the other direction.
|
||||
const MAX_DRAG_STAGE_BYTES: u64 = 256 * 1024 * 1024;
|
||||
|
||||
/// Name of the app-owned directory inside the OS temp dir. Everything staged by
|
||||
/// any Triple-C process lives under it, so housekeeping has exactly one place to
|
||||
/// look and never walks the rest of the user's temp dir.
|
||||
const DRAG_STAGE_DIR_NAME: &str = "triple-c-drag-out";
|
||||
|
||||
/// How long *another* process's leftover staging directory may sit before
|
||||
/// startup housekeeping deletes it.
|
||||
///
|
||||
/// Only ever applied to directories this process does not own (see
|
||||
/// [`drag_stage_session_dir`]), so it is not a limit on how long a staged file
|
||||
/// survives in a live session — it is the crash-recovery threshold, and it is
|
||||
/// generous because a second Triple-C running right now would also look like a
|
||||
/// leftover.
|
||||
const DRAG_STAGE_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
/// This process's own sub-directory name, stable for the life of the process.
|
||||
///
|
||||
/// Per-process rather than shared so exit cleanup can delete *ours* outright
|
||||
/// without reaching into a directory another instance may be dragging out of.
|
||||
fn drag_stage_session() -> &'static str {
|
||||
static SESSION: OnceLock<String> = OnceLock::new();
|
||||
SESSION.get_or_init(|| uuid::Uuid::new_v4().to_string())
|
||||
}
|
||||
|
||||
/// The app-owned staging root inside `temp_dir`.
|
||||
///
|
||||
/// Takes the temp dir rather than reading it, because on Windows it is neither
|
||||
/// `/tmp` nor a constant — Tauri's path API is the only thing that knows it —
|
||||
/// and because a pure function is what the tests can drive.
|
||||
pub fn drag_stage_root(temp_dir: &Path) -> PathBuf {
|
||||
temp_dir.join(DRAG_STAGE_DIR_NAME)
|
||||
}
|
||||
|
||||
/// This process's staging directory: `<temp>/triple-c-drag-out/<session>`.
|
||||
pub fn drag_stage_session_dir(temp_dir: &Path) -> PathBuf {
|
||||
drag_stage_root(temp_dir).join(drag_stage_session())
|
||||
}
|
||||
|
||||
/// The per-file sub-directory a staged copy lives in, derived from the
|
||||
/// container path.
|
||||
///
|
||||
/// Filenames are only unique within a directory, so `a/notes.txt` and
|
||||
/// `b/notes.txt` would otherwise be the same host path — and the second drag
|
||||
/// would silently rewrite the first one's contents under the first one's cached
|
||||
/// path. A digest of the full container path separates them while staying
|
||||
/// *deterministic*, so re-staging the same file reuses its slot instead of
|
||||
/// growing a new one every drag.
|
||||
fn drag_stage_slot(container_path: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let digest = Sha256::digest(container_path.as_bytes());
|
||||
digest[..8].iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
|
||||
/// The name the staged copy is given on the host.
|
||||
///
|
||||
/// The whole point is that what lands on the desktop is called `notes.txt` and
|
||||
/// not `tmp1234`, so the container's basename is kept verbatim wherever it can
|
||||
/// be. Only the characters Windows refuses outright are substituted — a Linux
|
||||
/// file really can be called `a:b`, and the staged copy has to exist on NTFS.
|
||||
/// A name that is not a filename at all (empty, `.`, `..`) is rejected rather
|
||||
/// than invented: that means the caller passed something that never named a
|
||||
/// file, and quietly inventing a name would stage the wrong thing.
|
||||
fn stage_file_name(container_path: &str) -> Result<String, String> {
|
||||
let base = container_path
|
||||
.trim_end_matches('/')
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
|
||||
let cleaned: String = base
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
|
||||
c if (c as u32) < 0x20 => '_',
|
||||
c => c,
|
||||
})
|
||||
.collect();
|
||||
// Windows also silently drops a trailing dot or space, which would make the
|
||||
// path we hand back not the path that exists.
|
||||
let cleaned = cleaned.trim_end_matches([' ', '.']);
|
||||
|
||||
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
|
||||
return Err(format!("{} does not name a file", container_path));
|
||||
}
|
||||
Ok(cleaned.to_string())
|
||||
}
|
||||
|
||||
/// Reject an oversize file *by its real size*, before anything is written.
|
||||
///
|
||||
/// Split out so the ceiling and its wording are testable without a container.
|
||||
/// The message names the fallback, because "too large" with no way forward is
|
||||
/// the one thing a size cap must not be.
|
||||
fn check_stage_size(size: u64) -> Result<(), String> {
|
||||
if size > MAX_DRAG_STAGE_BYTES {
|
||||
return Err(format!(
|
||||
"{:.0} MB is too large to drag out (limit {} MB) — use \"Save to host…\" instead.",
|
||||
size as f64 / (1024.0 * 1024.0),
|
||||
MAX_DRAG_STAGE_BYTES / (1024 * 1024)
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a leftover staging directory is old enough to delete.
|
||||
///
|
||||
/// A modification time in the *future* (a clock step, a copied temp dir) makes
|
||||
/// `duration_since` fail, and that answers "not stale" — housekeeping deleting
|
||||
/// something it cannot date is worse than leaving it for the next startup.
|
||||
fn drag_stage_is_stale(modified: SystemTime, now: SystemTime, max_age: Duration) -> bool {
|
||||
now.duration_since(modified)
|
||||
.map(|age| age >= max_age)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Delete every staging directory except this process's own, once it is older
|
||||
/// than [`DRAG_STAGE_MAX_AGE`]. Called from startup housekeeping.
|
||||
pub async fn reap_drag_staging(temp_dir: PathBuf) {
|
||||
let root = drag_stage_root(&temp_dir);
|
||||
let keep = drag_stage_session_dir(&temp_dir);
|
||||
let now = SystemTime::now();
|
||||
|
||||
let mut dir = match tokio::fs::read_dir(&root).await {
|
||||
Ok(dir) => dir,
|
||||
// Nothing staged yet is the normal case, not a problem.
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut reaped = 0usize;
|
||||
while let Ok(Some(entry)) = dir.next_entry().await {
|
||||
let path = entry.path();
|
||||
if path == keep {
|
||||
continue;
|
||||
}
|
||||
let stale = match entry.metadata().await.and_then(|m| m.modified()) {
|
||||
Ok(modified) => drag_stage_is_stale(modified, now, DRAG_STAGE_MAX_AGE),
|
||||
Err(_) => false,
|
||||
};
|
||||
if !stale {
|
||||
continue;
|
||||
}
|
||||
if tokio::fs::remove_dir_all(&path).await.is_ok() {
|
||||
reaped += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if reaped > 0 {
|
||||
log::info!("Startup housekeeping removed {} stale drag-out staging directory(ies)", reaped);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete this process's staging directory. Called from the shutdown teardown.
|
||||
pub async fn clear_drag_staging(temp_dir: PathBuf) {
|
||||
let dir = drag_stage_session_dir(&temp_dir);
|
||||
if let Err(e) = tokio::fs::remove_dir_all(&dir).await {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
log::warn!("Failed to clear drag-out staging at {}: {}", dir.display(), e);
|
||||
}
|
||||
}
|
||||
// Best effort: leave no empty root behind either. Fails harmlessly while
|
||||
// another instance still has a directory in there.
|
||||
let _ = tokio::fs::remove_dir(drag_stage_root(&temp_dir)).await;
|
||||
}
|
||||
|
||||
/// Copy a container file onto the host so it can be dragged to the desktop, and
|
||||
/// return the absolute host path.
|
||||
///
|
||||
/// Reuses [`fetch_container_file`] rather than extracting a second way, so a
|
||||
/// dragged file, a downloaded file and a previewed file are byte-identical and
|
||||
/// refuse folders and links with the same words. The fetch is capped at
|
||||
/// [`MAX_DRAG_STAGE_BYTES`], so an oversize file is recognised from the tar
|
||||
/// header without being pulled across the socket in full.
|
||||
#[tauri::command]
|
||||
pub async fn stage_container_file_for_drag(
|
||||
app: AppHandle,
|
||||
project_id: String,
|
||||
path: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
validate_container_path("File", &path)?;
|
||||
|
||||
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())?;
|
||||
|
||||
// Before the transfer: a path that cannot become a host filename is not
|
||||
// worth a round trip.
|
||||
let file_name = stage_file_name(&path)?;
|
||||
|
||||
let fetched = fetch_container_file(container_id, &path, MAX_DRAG_STAGE_BYTES).await?;
|
||||
// `size` is the tar entry's, i.e. the file's real size, which is exactly
|
||||
// what a truncated fetch does not tell you from `bytes.len()`.
|
||||
check_stage_size(fetched.size)?;
|
||||
|
||||
let temp_dir = app
|
||||
.path()
|
||||
.temp_dir()
|
||||
.map_err(|e| format!("No host temporary directory available: {}", e))?;
|
||||
let dir = drag_stage_session_dir(&temp_dir).join(drag_stage_slot(&path));
|
||||
tokio::fs::create_dir_all(&dir)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create the drag staging directory: {}", e))?;
|
||||
|
||||
let dest = dir.join(&file_name);
|
||||
tokio::fs::write(&dest, &fetched.bytes)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to stage {} on the host: {}", file_name, e))?;
|
||||
|
||||
Ok(dest.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Rename an entry in place. `to_path` is the **new name**, not a destination
|
||||
/// path — moving between directories is deliberately not offered here, so the
|
||||
/// name is validated to carry no `/`.
|
||||
@@ -2425,8 +2189,7 @@ mod tests {
|
||||
// `fetch_container_file` takes a plain `u64` now, so the `None` that
|
||||
// made the cap inert cannot be written again. These are the two callers
|
||||
// left, and both buffer.
|
||||
assert!(MAX_READ_BYTES <= MAX_DRAG_STAGE_BYTES);
|
||||
assert!(MAX_DRAG_STAGE_BYTES < MAX_DOWNLOAD_BYTES);
|
||||
assert!(MAX_READ_BYTES < MAX_DOWNLOAD_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2445,116 +2208,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Drag-out staging ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn the_staging_path_is_built_under_the_supplied_temp_dir() {
|
||||
// Never `/tmp`: on Windows the temp dir is per-user and nowhere near it,
|
||||
// so the whole path has to be derived from what Tauri hands us.
|
||||
let temp = Path::new("/somewhere/else");
|
||||
let root = drag_stage_root(temp);
|
||||
assert_eq!(root, Path::new("/somewhere/else/triple-c-drag-out"));
|
||||
|
||||
let session = drag_stage_session_dir(temp);
|
||||
assert_eq!(session.parent(), Some(root.as_path()));
|
||||
assert!(session.starts_with(root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_call_in_a_process_stages_into_the_same_session_directory() {
|
||||
// Exit cleanup deletes this directory by name rather than tracking what
|
||||
// it wrote, which only works if the name does not move.
|
||||
let temp = Path::new("/tmp-ish");
|
||||
assert_eq!(drag_stage_session_dir(temp), drag_stage_session_dir(temp));
|
||||
assert_ne!(drag_stage_session_dir(temp), drag_stage_root(temp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_staged_copy_keeps_the_original_file_name() {
|
||||
// The reason the feature stages into a per-session directory at all: a
|
||||
// plain temp file would be dropped onto the desktop called `tmp1234`.
|
||||
assert_eq!(stage_file_name("/workspace/notes.txt").unwrap(), "notes.txt");
|
||||
assert_eq!(stage_file_name("/workspace/a b/.env").unwrap(), ".env");
|
||||
assert_eq!(stage_file_name("report.pdf").unwrap(), "report.pdf");
|
||||
assert_eq!(stage_file_name("/workspace/über.md").unwrap(), "über.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_windows_cannot_hold_is_substituted_rather_than_dropped() {
|
||||
// These are all legal on Linux and all refused by NTFS, and the staged
|
||||
// copy has to exist on the host we are dragging onto.
|
||||
assert_eq!(stage_file_name("/workspace/a:b.txt").unwrap(), "a_b.txt");
|
||||
assert_eq!(stage_file_name("/workspace/q?.log").unwrap(), "q_.log");
|
||||
assert_eq!(stage_file_name("/workspace/a\\b").unwrap(), "a_b");
|
||||
// A trailing dot or space is not refused, it is silently dropped — so
|
||||
// the path we return would not be the path that exists.
|
||||
assert_eq!(stage_file_name("/workspace/trailing. ").unwrap(), "trailing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_that_does_not_name_a_file_is_refused_not_invented() {
|
||||
assert!(stage_file_name("/").is_err());
|
||||
assert!(stage_file_name("").is_err());
|
||||
assert!(stage_file_name("/workspace/..").is_err());
|
||||
assert!(stage_file_name("/workspace/.").is_err());
|
||||
// Trims down to nothing, which is the same problem one step later.
|
||||
assert!(stage_file_name("/workspace/...").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_files_with_the_same_name_stage_to_different_places() {
|
||||
// Names are unique per directory, not per container — and the second
|
||||
// drag would otherwise rewrite the first one's bytes under the path the
|
||||
// first one is still cached at.
|
||||
assert_ne!(
|
||||
drag_stage_slot("/workspace/a/notes.txt"),
|
||||
drag_stage_slot("/workspace/b/notes.txt")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_staging_the_same_file_reuses_its_slot() {
|
||||
// Deterministic, so a file dragged repeatedly does not grow a new
|
||||
// directory in the host temp dir every time.
|
||||
assert_eq!(
|
||||
drag_stage_slot("/workspace/notes.txt"),
|
||||
drag_stage_slot("/workspace/notes.txt")
|
||||
);
|
||||
// Short enough to keep the path sane, long enough not to collide.
|
||||
assert_eq!(drag_stage_slot("/workspace/notes.txt").len(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_drag_size_cap_matches_the_established_ceiling_and_names_the_fallback() {
|
||||
assert_eq!(MAX_DRAG_STAGE_BYTES, MAX_UPLOAD_BYTES);
|
||||
assert!(check_stage_size(MAX_DRAG_STAGE_BYTES).is_ok());
|
||||
|
||||
let err = check_stage_size(MAX_DRAG_STAGE_BYTES + 1).unwrap_err();
|
||||
assert!(err.contains("256 MB"), "{}", err);
|
||||
// A size cap with no way forward is the one thing this must not be.
|
||||
assert!(err.contains("Save to host"), "{}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reaper_only_takes_entries_past_the_age_threshold() {
|
||||
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
|
||||
let age = Duration::from_secs(3_600);
|
||||
|
||||
assert!(drag_stage_is_stale(now - Duration::from_secs(3_601), now, age));
|
||||
assert!(drag_stage_is_stale(now - age, now, age));
|
||||
assert!(!drag_stage_is_stale(now - Duration::from_secs(3_599), now, age));
|
||||
assert!(!drag_stage_is_stale(now, now, age));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_future_timestamp_is_left_alone_rather_than_reaped() {
|
||||
// A clock step must not turn housekeeping into deletion of something it
|
||||
// cannot date.
|
||||
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
|
||||
let age = Duration::from_secs(3_600);
|
||||
assert!(!drag_stage_is_stale(now + Duration::from_secs(60), now, age));
|
||||
}
|
||||
|
||||
// ── Host path normalisation, on every platform ──────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user