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:
Generated
+15
-185
@@ -630,19 +630,6 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "core-graphics"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-graphics"
|
||||
version = "0.25.0"
|
||||
@@ -1029,28 +1016,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "drag"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e90b4a25ace5ce0561534b073943594cbcd21af936e64d09aec444568411f8c"
|
||||
dependencies = [
|
||||
"core-graphics 0.24.0",
|
||||
"dunce",
|
||||
"gdk",
|
||||
"gdkx11",
|
||||
"gtk",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"windows 0.52.0",
|
||||
"windows-core 0.58.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dtoa"
|
||||
version = "1.0.11"
|
||||
@@ -2682,17 +2647,9 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-image",
|
||||
"objc2-core-text",
|
||||
"objc2-core-video",
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2712,7 +2669,6 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
@@ -2773,19 +2729,6 @@ dependencies = [
|
||||
"objc2-core-graphics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-video"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-io-surface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@@ -4457,7 +4400,7 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics 0.25.0",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dbus",
|
||||
"dispatch2",
|
||||
@@ -4482,7 +4425,7 @@ dependencies = [
|
||||
"tao-macros",
|
||||
"unicode-segmentation",
|
||||
"url",
|
||||
"windows 0.61.3",
|
||||
"windows",
|
||||
"windows-core 0.61.2",
|
||||
"windows-version",
|
||||
"x11-dl",
|
||||
@@ -4565,7 +4508,7 @@ dependencies = [
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"window-vibrancy",
|
||||
"windows 0.61.3",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4665,21 +4608,6 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-drag"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "729ca0ce4b1169869d3405216d3c09a524f41ea5e2eec89f917cd6623f8a70ca"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"drag",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.5.0"
|
||||
@@ -4722,7 +4650,7 @@ dependencies = [
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
"windows 0.61.3",
|
||||
"windows",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
@@ -4748,7 +4676,7 @@ dependencies = [
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows 0.61.3",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4773,7 +4701,7 @@ dependencies = [
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows 0.61.3",
|
||||
"windows",
|
||||
"wry",
|
||||
]
|
||||
|
||||
@@ -5242,7 +5170,6 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-drag",
|
||||
"tauri-plugin-opener",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
@@ -5695,10 +5622,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
|
||||
dependencies = [
|
||||
"webview2-com-macros",
|
||||
"webview2-com-sys",
|
||||
"windows 0.61.3",
|
||||
"windows",
|
||||
"windows-core 0.61.2",
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5719,7 +5646,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"windows 0.61.3",
|
||||
"windows",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
@@ -5769,18 +5696,6 @@ dependencies = [
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
|
||||
dependencies = [
|
||||
"windows-core 0.52.0",
|
||||
"windows-implement 0.52.0",
|
||||
"windows-interface 0.52.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.61.3"
|
||||
@@ -5803,36 +5718,14 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
|
||||
dependencies = [
|
||||
"windows-implement 0.58.0",
|
||||
"windows-interface 0.58.0",
|
||||
"windows-result 0.2.0",
|
||||
"windows-strings 0.1.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||
dependencies = [
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
@@ -5844,8 +5737,8 @@ version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
@@ -5862,28 +5755,6 @@ dependencies = [
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12168c33176773b86799be25e2a2ba07c7aab9968b37541f1094dbd7a60c8946"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
@@ -5895,28 +5766,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d8dc32e0095a7eeccebd0e3f09e9509365ecb3fc6ac4d6f5f14a3f6392942d1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
@@ -5950,15 +5799,6 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
@@ -5977,16 +5817,6 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
||||
dependencies = [
|
||||
"windows-result 0.2.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.4.2"
|
||||
@@ -6414,7 +6244,7 @@ dependencies = [
|
||||
"webkit2gtk",
|
||||
"webkit2gtk-sys",
|
||||
"webview2-com",
|
||||
"windows 0.61.3",
|
||||
"windows",
|
||||
"windows-core 0.61.2",
|
||||
"windows-version",
|
||||
"x11-dl",
|
||||
|
||||
@@ -36,7 +36,6 @@ tower-http = { version = "0.6", features = ["cors"] }
|
||||
base64 = "0.22"
|
||||
rand = "0.9"
|
||||
local-ip-address = "0.6"
|
||||
tauri-plugin-drag = "2.1"
|
||||
|
||||
[dev-dependencies]
|
||||
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — verified against tauri 2.11.0's `PLUGINS` table in `build.rs`, not assumed from a plugin's `default` set. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource` (`startDrag`'s `Channel` is not one); and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` cannot be narrowed by host. `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist here would delete the feature rather than bound it. What *is* bounded: `opener:default` is not used, so `open_path` and `reveal_item_in_dir` are absent; the scope's two entries restrict the scheme to http/https (`file:`, `mailto:`, `tel:`, `smb:` are all refused by `Scope::is_url_allowed`); and because each entry leaves `app` at its serde default of `Application::Default`, which matches only `with == None`, `openUrl(url, \"/bin/sh\")` is refused — the `with` argument is not a usable exec primitive. The call sites re-validate through `sanitizeRelayUrl` (scheme allowlist, no embedded credentials, length cap) before anything reaches the opener. Accepted residual risk: a compromised webview can make the OS open an attacker-chosen http(s) URL, which is an outbound channel. Recorded here rather than fixed. (3) `drag:allow-start-drag` stays, and cannot be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read. It is not a silent exfiltration primitive: the drag only delivers anything if the user completes a real drop onto a real target, and the OS shows the drag under the cursor while it is in flight. Removing it would remove drag-out from the Files pane (`stage_container_file_for_drag`), whose fallback is the explicit \"Save to host…\" action. Accepted residual risk. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.",
|
||||
"description": "Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — verified against tauri 2.11.0's `PLUGINS` table in `build.rs`, not assumed from a plugin's `default` set. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` cannot be narrowed by host. `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist here would delete the feature rather than bound it. What *is* bounded: `opener:default` is not used, so `open_path` and `reveal_item_in_dir` are absent; the scope's two entries restrict the scheme to http/https (`file:`, `mailto:`, `tel:`, `smb:` are all refused by `Scope::is_url_allowed`); and because each entry leaves `app` at its serde default of `Application::Default`, which matches only `with == None`, `openUrl(url, \"/bin/sh\")` is refused — the `with` argument is not a usable exec primitive. The call sites re-validate through `sanitizeRelayUrl` (scheme allowlist, no embedded credentials, length cap) before anything reaches the opener. Accepted residual risk: a compromised webview can make the OS open an attacker-chosen http(s) URL, which is an outbound channel. Recorded here rather than fixed. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`), the plugin is no longer a dependency, and getting a file out of a container is now the explicit \"Save to host…\" action, which never touches this permission. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:event:allow-listen",
|
||||
@@ -11,7 +11,6 @@
|
||||
{
|
||||
"identifier": "opener:allow-open-url",
|
||||
"allow": [{ "url": "http://*" }, { "url": "https://*" }]
|
||||
},
|
||||
"drag:allow-start-drag"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — verified against tauri 2.11.0's `PLUGINS` table in `build.rs`, not assumed from a plugin's `default` set. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource` (`startDrag`'s `Channel` is not one); and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` cannot be narrowed by host. `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist here would delete the feature rather than bound it. What *is* bounded: `opener:default` is not used, so `open_path` and `reveal_item_in_dir` are absent; the scope's two entries restrict the scheme to http/https (`file:`, `mailto:`, `tel:`, `smb:` are all refused by `Scope::is_url_allowed`); and because each entry leaves `app` at its serde default of `Application::Default`, which matches only `with == None`, `openUrl(url, \"/bin/sh\")` is refused — the `with` argument is not a usable exec primitive. The call sites re-validate through `sanitizeRelayUrl` (scheme allowlist, no embedded credentials, length cap) before anything reaches the opener. Accepted residual risk: a compromised webview can make the OS open an attacker-chosen http(s) URL, which is an outbound channel. Recorded here rather than fixed. (3) `drag:allow-start-drag` stays, and cannot be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read. It is not a silent exfiltration primitive: the drag only delivers anything if the user completes a real drop onto a real target, and the OS shows the drag under the cursor while it is in flight. Removing it would remove drag-out from the Files pane (`stage_container_file_for_drag`), whose fallback is the explicit \"Save to host…\" action. Accepted residual risk. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.","local":true,"windows":["main"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:webview:allow-internal-toggle-devtools","dialog:allow-open","dialog:allow-save",{"identifier":"opener:allow-open-url","allow":[{"url":"http://*"},{"url":"https://*"}]},"drag:allow-start-drag"]}}
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — verified against tauri 2.11.0's `PLUGINS` table in `build.rs`, not assumed from a plugin's `default` set. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` cannot be narrowed by host. `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist here would delete the feature rather than bound it. What *is* bounded: `opener:default` is not used, so `open_path` and `reveal_item_in_dir` are absent; the scope's two entries restrict the scheme to http/https (`file:`, `mailto:`, `tel:`, `smb:` are all refused by `Scope::is_url_allowed`); and because each entry leaves `app` at its serde default of `Application::Default`, which matches only `with == None`, `openUrl(url, \"/bin/sh\")` is refused — the `with` argument is not a usable exec primitive. The call sites re-validate through `sanitizeRelayUrl` (scheme allowlist, no embedded credentials, length cap) before anything reaches the opener. Accepted residual risk: a compromised webview can make the OS open an attacker-chosen http(s) URL, which is an outbound channel. Recorded here rather than fixed. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`), the plugin is no longer a dependency, and getting a file out of a container is now the explicit \"Save to host…\" action, which never touches this permission. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.","local":true,"windows":["main"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:webview:allow-internal-toggle-devtools","dialog:allow-open","dialog:allow-save",{"identifier":"opener:allow-open-url","allow":[{"url":"http://*"},{"url":"https://*"}]}]}}
|
||||
@@ -2426,24 +2426,6 @@
|
||||
"const": "dialog:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start-drag`",
|
||||
"type": "string",
|
||||
"const": "drag:default",
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start-drag`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the start_drag command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "drag:allow-start-drag",
|
||||
"markdownDescription": "Enables the start_drag command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the start_drag command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "drag:deny-start-drag",
|
||||
"markdownDescription": "Denies the start_drag command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||
"type": "string",
|
||||
|
||||
@@ -2426,24 +2426,6 @@
|
||||
"const": "dialog:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start-drag`",
|
||||
"type": "string",
|
||||
"const": "drag:default",
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start-drag`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the start_drag command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "drag:allow-start-drag",
|
||||
"markdownDescription": "Enables the start_drag command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the start_drag command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "drag:deny-start-drag",
|
||||
"markdownDescription": "Denies the start_drag command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||
"type": "string",
|
||||
|
||||
@@ -54,80 +54,3 @@ pub async fn list_sibling_containers() -> Result<Vec<serde_json::Value>, String>
|
||||
.collect();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Disk
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The disk view's IPC surface. It lives here rather than in a module of its own
|
||||
// for the same reason `check_image_exists` does: these are thin shims over
|
||||
// `crate::docker`, and the logic they call is in `docker/disk.rs` where it can
|
||||
// be unit-tested without a daemon.
|
||||
|
||||
/// Measure where the daemon's bytes have gone.
|
||||
///
|
||||
/// **Expensive on purpose.** This is `GET /system/df` plus an `image_history`
|
||||
/// per distinct image, and `df()` walks every image, container and volume on
|
||||
/// the daemon to compute shared-layer sizes. On a 100 GB store that is seconds.
|
||||
/// The frontend must keep it behind an explicit Scan button — never on panel
|
||||
/// open, never on a timer.
|
||||
#[tauri::command]
|
||||
pub async fn get_docker_disk_usage(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<docker::disk::DiskUsageReport, String> {
|
||||
let projects = state.projects_store.list();
|
||||
docker::disk::scan(&projects).await
|
||||
}
|
||||
|
||||
/// Everything that could be reclaimed, each with its measured cost.
|
||||
///
|
||||
/// Takes the report from [`get_docker_disk_usage`] rather than re-measuring, so
|
||||
/// a user who re-plans after ticking a box does not pay for a second `df()`.
|
||||
#[tauri::command]
|
||||
pub async fn list_reclaimable(
|
||||
report: docker::disk::DiskUsageReport,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<docker::disk::ReclaimPlan, String> {
|
||||
let projects = state.projects_store.list();
|
||||
docker::disk::list_reclaimable(&projects, &report).await
|
||||
}
|
||||
|
||||
/// Run the ticked targets and report what each one actually freed.
|
||||
///
|
||||
/// `ReclaimTarget` cannot express a destructive action — that is a different
|
||||
/// type, reached only through [`destroy_project_disk_object`] with a typed
|
||||
/// confirmation — so there is no selection a user can build here that deletes a
|
||||
/// live project's data.
|
||||
#[tauri::command]
|
||||
pub async fn reclaim(
|
||||
targets: Vec<docker::disk::ReclaimTarget>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<docker::disk::ReclaimOutcome, String> {
|
||||
let projects = state.projects_store.list();
|
||||
Ok(docker::disk::reclaim(&targets, &projects).await)
|
||||
}
|
||||
|
||||
/// Delete one object that has no other copy, against a typed confirmation of
|
||||
/// the project's name.
|
||||
///
|
||||
/// Deliberately one target per call: this is never part of a bulk action.
|
||||
#[tauri::command]
|
||||
pub async fn destroy_project_disk_object(
|
||||
target: docker::disk::DestructiveTarget,
|
||||
confirmation: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<docker::disk::ReclaimResult, String> {
|
||||
let projects = state.projects_store.list();
|
||||
docker::disk::destroy(&target, &confirmation, &projects).await
|
||||
}
|
||||
|
||||
/// Run the orphaned-snapshot sweep on demand and return its report.
|
||||
///
|
||||
/// The sweep already runs at startup, after every recreation and after a
|
||||
/// migration settles, but every one of those callers throws the report away —
|
||||
/// so a user has never been able to see that 11.9 GB of superseded images were
|
||||
/// found and left because a stopped container still pinned them.
|
||||
#[tauri::command]
|
||||
pub async fn sweep_orphaned_snapshots() -> Result<docker::SnapshotSweepReport, String> {
|
||||
Ok(docker::sweep_orphaned_snapshots().await)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -243,6 +243,13 @@ async fn container_label(container_id: &str, label: &str) -> Option<String> {
|
||||
/// function is the specialisation of it that reconcile still needs: a *live*
|
||||
/// migration is indistinguishable from a crashed one from the outside, and only
|
||||
/// this process knows which it is looking at.
|
||||
///
|
||||
/// No production caller on this branch: the Disk panel's survey was the last
|
||||
/// one, and it went to `hold/disk-and-dragout`. Kept — and still exercised by
|
||||
/// `a_live_migration_is_distinguishable_from_a_crashed_one` — because it is the
|
||||
/// one named answer to that question and re-inventing it is how the two
|
||||
/// disagreeing answers happened the first time.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn is_migrating(project_id: &str) -> bool {
|
||||
crate::project_lock::is_held_by(project_id, crate::project_lock::ProjectOp::Migration)
|
||||
}
|
||||
|
||||
@@ -2184,8 +2184,8 @@ pub(crate) fn snapshot_scrub_script() -> String {
|
||||
/// expands to itself and is skipped by the existence guard.
|
||||
///
|
||||
/// Passing the whole pattern rather than the two halves also keeps each entry
|
||||
/// readable verbatim in the compaction `RUN` line — `disk.rs` asserts exactly
|
||||
/// that, to catch a second forked copy of the list.
|
||||
/// readable verbatim in the generated script, so a test can assert the script
|
||||
/// names this list rather than a second forked copy of it.
|
||||
///
|
||||
/// ## The containment guarantee (C1)
|
||||
///
|
||||
@@ -2258,15 +2258,15 @@ pub(crate) fn snapshot_scrub_script() -> String {
|
||||
/// which the agent's passwordless sudo can. It closes the part of the gap that
|
||||
/// survives a container restart and needs no privileges at all.
|
||||
///
|
||||
/// ## Why every line ends in `;`
|
||||
/// ## Why every line ends in `;`, and why there are no `#` comments
|
||||
///
|
||||
/// `disk.rs` folds this script onto a single `RUN` line for the compaction
|
||||
/// build, joining non-blank lines with a space. That is only a join and not a
|
||||
/// rewrite if each line already terminates its own statement — the previous
|
||||
/// version did not, and its folded form was a `"do" unexpected` syntax error,
|
||||
/// so compaction had been running no scrub at all. It also means the script
|
||||
/// carries **no `#` comments**: folded, one would swallow the rest of the
|
||||
/// program. A test pins both the multi-line and the folded form.
|
||||
/// A self-terminating statement per line, and no comments, is what makes the
|
||||
/// script safe to join onto one line: any embedder that folds it with spaces
|
||||
/// gets a join rather than a rewrite. That property was learnt the hard way —
|
||||
/// an earlier version's folded form was a `"do" unexpected` syntax error, so
|
||||
/// the scrub ran not at all — and it is kept even though the folding caller is
|
||||
/// gone, because a script that survives being flattened is the cheap invariant
|
||||
/// and re-learning it is not.
|
||||
///
|
||||
/// ## Why `root` exists
|
||||
///
|
||||
@@ -2903,8 +2903,8 @@ pub async fn scrub_secrets_from_snapshots() -> SnapshotScrubReport {
|
||||
|
||||
// Claim the project before touching its snapshot.
|
||||
//
|
||||
// This is the third writer of `triple-c-snapshot-{id}:latest`, after a
|
||||
// recreate's commit and a compaction, and it has the same
|
||||
// This is the second writer of `triple-c-snapshot-{id}:latest`, after a
|
||||
// recreate's commit, and it has the same
|
||||
// read-modify-write shape: create a scratch container *from* the
|
||||
// snapshot, then commit back over the same tag. A `:latest` move
|
||||
// landing in between is silently overwritten by an image derived from
|
||||
@@ -4233,8 +4233,7 @@ mod tests {
|
||||
assert!(script.contains("scrub_in '/var/log/apt/*' '-';"));
|
||||
assert!(script.contains("scrub_in '/tmp/triple-c-drops/*' '14';"));
|
||||
// The parent/glob split happens in the shell, so every entry stays
|
||||
// readable verbatim — `disk.rs` folds this onto one `RUN` line and
|
||||
// asserts each pattern appears there rather than a forked copy.
|
||||
// readable verbatim in the script rather than as a forked copy.
|
||||
for pattern in SNAPSHOT_SCRUB_PATHS {
|
||||
assert!(script.contains(pattern), "{} is not named in the script", pattern);
|
||||
}
|
||||
@@ -4572,51 +4571,6 @@ mod tests {
|
||||
assert!(SCRUB_TIMEOUT.as_secs() <= 300, "long enough that a user would force-quit first");
|
||||
}
|
||||
|
||||
/// `disk.rs` folds this script onto one `RUN` line for the compaction
|
||||
/// build by joining its non-blank lines with a space, so the script has to
|
||||
/// be a sequence of self-terminating statements and carry no `#` comments.
|
||||
/// The previous version was neither: its folded form was a `"do"
|
||||
/// unexpected` syntax error, which means compaction had been scrubbing
|
||||
/// nothing at all. The fold is reproduced here rather than imported
|
||||
/// because it is private to the other module — a divergence would show up
|
||||
/// as this test passing while the real Dockerfile broke, so it is pinned
|
||||
/// against the same wording in `fold_shell_script`.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_compaction_runs_this_module_s_scrub_script_byte_for_byte() {
|
||||
// The compaction build used to fold the script onto one `RUN` line by
|
||||
// joining its lines with a space, which turned `for p in …; do` into
|
||||
// `do` in statement position and made every compaction fail with
|
||||
// `syntax error: unexpected "do"`. That fold is gone — `disk.rs` now
|
||||
// emits the JSON exec form, whose string escapes carry newlines — so
|
||||
// the assertion worth pinning from this side is no longer "the folded
|
||||
// one-liner still parses" but the stronger one: whatever encoding
|
||||
// `disk.rs` chooses, the bytes that reach `sh` are *this* script.
|
||||
//
|
||||
// This is what stops the two files drifting. `container.rs` owns the
|
||||
// containment rules in `snapshot_scrub_script`; a compaction that ran a
|
||||
// mangled copy would be running a scrub with those rules altered, and
|
||||
// the mangling would be silent.
|
||||
let expected = snapshot_scrub_script();
|
||||
// Build the real Dockerfile the compaction would, then pull the script
|
||||
// back out of it — going through `compaction_dockerfile` rather than a
|
||||
// helper means a change to how the RUN line is emitted is caught here.
|
||||
let dockerfile = crate::docker::disk::compaction_dockerfile(
|
||||
"triple-c-snapshot-00000000-0000-0000-0000-000000000000:latest",
|
||||
&expected,
|
||||
);
|
||||
let run_line = dockerfile
|
||||
.lines()
|
||||
.find(|l| l.starts_with("RUN "))
|
||||
.expect("the compaction Dockerfile should carry a RUN line");
|
||||
let actual = crate::docker::disk::script_from_run_line(run_line)
|
||||
.expect("the compaction RUN line should be the JSON exec form");
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"the compaction runs a different script than snapshot_scrub_script() produces"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_container_is_created_with_a_bounded_log() {
|
||||
let cfg = capped_log_config();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
pub mod ca_certs;
|
||||
pub mod client;
|
||||
pub mod container;
|
||||
pub mod disk;
|
||||
pub mod image;
|
||||
pub mod exec;
|
||||
pub mod gateway;
|
||||
@@ -25,10 +24,6 @@ pub use exec::*;
|
||||
pub use legacy_cleanup::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use migration::*;
|
||||
// `disk` is also deliberately kept namespaced. Its `scan`, `reclaim` and
|
||||
// `destroy` are meaningless as bare names, and `disk::destroy` reading as what
|
||||
// it is at every call site is worth more than the brevity.
|
||||
|
||||
// Deliberately *not* re-exported flat: `ca_certs::resolve` and
|
||||
// `ca_certs::CA_MOUNT_DIR` are far clearer than bare `resolve` in a module that
|
||||
// already re-exports five other namespaces.
|
||||
|
||||
@@ -215,11 +215,6 @@ pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
// Drag a file from the Files tab onto the host desktop. The gesture is
|
||||
// pointer-driven for the same reason the tab drag is (see MainTabs):
|
||||
// `dragDropEnabled` is on for the terminal's sake and blocks HTML5 drag
|
||||
// inside the webview, so this plugin's native drag is the only route out.
|
||||
.plugin(tauri_plugin_drag::init())
|
||||
.manage(AppState {
|
||||
projects_store,
|
||||
settings_store,
|
||||
@@ -245,8 +240,8 @@ pub fn run() {
|
||||
// simply stopped launching a project kept its orphaned snapshot
|
||||
// layers forever, and anything a crash left behind (a probe
|
||||
// container pinning a base image, a rollback pin whose migration
|
||||
// record is gone) had no path back at all. All three are
|
||||
// read-mostly and finish in well under a second on an idle daemon,
|
||||
// record is gone) had no path back at all. All of it is
|
||||
// read-mostly and finishes in well under a second on an idle daemon,
|
||||
// but they are detached anyway: housekeeping must never delay the
|
||||
// window appearing, and a daemon that is not running yet is a
|
||||
// logged warning rather than a failed start.
|
||||
@@ -255,35 +250,13 @@ pub fn run() {
|
||||
// an image open and the sweep will not force; pins are untagged
|
||||
// second so the images they were holding are dangling by the time
|
||||
// the sweep lists them; the sweep runs last and collects both.
|
||||
//
|
||||
// Drag-out staging is swept here too, and it is the *other* half of
|
||||
// a lifecycle whose first half is the exit cleanup below: a run that
|
||||
// crashed never got to clear its staged copies, and those are whole
|
||||
// files, not metadata.
|
||||
let drag_temp_dir = app.path().temp_dir().ok();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
crate::docker::reap_probe_containers().await;
|
||||
// Before the sweep, and for the same reason the pins are:
|
||||
// `triple-c-snapshot-*:compacting` is a *tagged* image, so the
|
||||
// sweep's `dangling=true` filter cannot see it, and the
|
||||
// `triple-c-compact-*` container a crashed compaction leaves
|
||||
// behind pins that image open. Untagging first is what turns
|
||||
// both into something the sweep can collect on the same pass.
|
||||
let stranded = crate::docker::disk::reap_stale_compaction_artifacts().await;
|
||||
if stranded > 0 {
|
||||
log::info!(
|
||||
"Startup housekeeping dropped {} stranded compaction staging tag(s)",
|
||||
stranded
|
||||
);
|
||||
}
|
||||
let reaped = crate::docker::reap_stale_migration_pins().await;
|
||||
if reaped > 0 {
|
||||
log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped);
|
||||
}
|
||||
crate::docker::sweep_orphaned_snapshots_logged("startup").await;
|
||||
if let Some(temp_dir) = drag_temp_dir {
|
||||
commands::file_commands::reap_drag_staging(temp_dir).await;
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-start web terminal server if enabled in settings
|
||||
@@ -410,10 +383,6 @@ pub fn run() {
|
||||
let _ = window.emit("app-shutting-down", ());
|
||||
|
||||
let app_handle = window.app_handle().clone();
|
||||
// Resolved here rather than inside the teardown, which is
|
||||
// already under a wall-clock budget and should not spend any of
|
||||
// it asking where the temp dir is.
|
||||
let drag_temp_dir = app_handle.path().temp_dir().ok();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let teardown = async {
|
||||
// First: let the auto-starts unwind. Anything they are
|
||||
@@ -440,20 +409,10 @@ pub fn run() {
|
||||
log::warn!("Failed to stop the model gateway on exit: {}", e);
|
||||
}
|
||||
};
|
||||
// Whole files copied out of containers for drag-out.
|
||||
// Left behind they are a disk leak with a gesture
|
||||
// attached; startup housekeeping is the backstop for a
|
||||
// run that never reaches this point.
|
||||
let clear_drag_staging = async {
|
||||
if let Some(temp_dir) = drag_temp_dir {
|
||||
commands::file_commands::clear_drag_staging(temp_dir).await;
|
||||
}
|
||||
};
|
||||
tokio::join!(
|
||||
web_terminal,
|
||||
stop_stt,
|
||||
stop_gateway,
|
||||
clear_drag_staging,
|
||||
exec_manager.close_all_sessions(),
|
||||
auth_bridge.stop_all(),
|
||||
browser_view::manager().stop_all(),
|
||||
@@ -477,12 +436,6 @@ pub fn run() {
|
||||
commands::docker_commands::build_image,
|
||||
commands::docker_commands::get_container_info,
|
||||
commands::docker_commands::list_sibling_containers,
|
||||
// Disk
|
||||
commands::docker_commands::get_docker_disk_usage,
|
||||
commands::docker_commands::list_reclaimable,
|
||||
commands::docker_commands::reclaim,
|
||||
commands::docker_commands::destroy_project_disk_object,
|
||||
commands::docker_commands::sweep_orphaned_snapshots,
|
||||
// Projects
|
||||
commands::project_commands::list_projects,
|
||||
commands::project_commands::add_project,
|
||||
@@ -549,7 +502,6 @@ pub fn run() {
|
||||
commands::file_commands::read_container_file,
|
||||
commands::file_commands::rename_container_path,
|
||||
commands::file_commands::create_container_directory,
|
||||
commands::file_commands::stage_container_file_for_drag,
|
||||
// AWS
|
||||
commands::aws_commands::aws_sso_refresh,
|
||||
// Updates
|
||||
@@ -764,7 +716,6 @@ mod tests {
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"opener:allow-open-url",
|
||||
"drag:allow-start-drag",
|
||||
];
|
||||
expected.sort();
|
||||
assert_eq!(
|
||||
|
||||
@@ -61,8 +61,7 @@
|
||||
//! does do about it is bound the damage: [`any_held_excluding`] lets the daemon-wide
|
||||
//! reapers skip work while this process is mid-operation, and the reapers
|
||||
//! themselves gained age gates so a young container belonging to somebody else
|
||||
//! is left alone (see `docker::disk::reap_stale_compaction_artifacts` and
|
||||
//! `docker::migration::reap_probe_containers`).
|
||||
//! is left alone (see `docker::migration::reap_probe_containers`).
|
||||
//!
|
||||
//! ## Refuse, do not queue
|
||||
//!
|
||||
@@ -85,6 +84,13 @@ pub enum ProjectOp {
|
||||
/// `confirm_migration`.
|
||||
Migration,
|
||||
/// `disk::compact_snapshot` — the long one, and the reason this exists.
|
||||
///
|
||||
/// Not constructed on this branch: the Disk panel and its compaction were
|
||||
/// held back for separate hardening and live on `hold/disk-and-dragout`.
|
||||
/// The variant stays because this registry is the thing that made those
|
||||
/// operations safe to re-land, and a re-land that had to re-derive the
|
||||
/// claim classes would be re-deriving the bug.
|
||||
#[allow(dead_code)]
|
||||
Compaction,
|
||||
/// Start / stop / recreate. Anything in `start_project_container`'s path.
|
||||
Recreate,
|
||||
@@ -95,6 +101,10 @@ pub enum ProjectOp {
|
||||
/// `disk::clear_caches` — an exec into the live container. It does not
|
||||
/// write `:latest`, but it must not run while the container is being
|
||||
/// removed out from under it.
|
||||
///
|
||||
/// Not constructed on this branch, for the same reason as
|
||||
/// [`ProjectOp::Compaction`].
|
||||
#[allow(dead_code)]
|
||||
CacheClear,
|
||||
/// `container::scrub_secrets_from_snapshots` — the third writer of
|
||||
/// `triple-c-snapshot-{id}:latest`, reached from `clear_claude_token`. It
|
||||
@@ -220,6 +230,12 @@ pub fn is_held_by(project_id: &str, op: ProjectOp) -> bool {
|
||||
/// only in-process question they can ask before force-removing one. The
|
||||
/// exclusion is for the reaper that runs *inside* a compaction, which is
|
||||
/// already holding a claim of its own and would otherwise see it and skip.
|
||||
///
|
||||
/// No production caller on this branch: the compaction reaper it was written
|
||||
/// for went to `hold/disk-and-dragout` with the rest of the Disk panel. Kept
|
||||
/// (and still tested) because it is the only bound this module offers on the
|
||||
/// cross-process case documented above.
|
||||
#[allow(dead_code)]
|
||||
pub fn any_held_excluding(op: ProjectOp, exclude_project_id: &str) -> bool {
|
||||
holders()
|
||||
.lock()
|
||||
|
||||
@@ -267,22 +267,6 @@ fn ownerless_marker_path(project_id: &str, tag: &str) -> Result<PathBuf, String>
|
||||
)))
|
||||
}
|
||||
|
||||
/// When this pin was first observed ownerless, **without recording anything**.
|
||||
///
|
||||
/// For the survey paths, which describe the world and must not change it.
|
||||
/// `None` means "no reaper has seen it yet", which is not the same as "seen
|
||||
/// just now" and must not be treated as a start date.
|
||||
pub fn peek_ownerless_since(
|
||||
project_id: &str,
|
||||
tag: &str,
|
||||
) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||
let path = ownerless_marker_path(project_id, tag).ok()?;
|
||||
let raw = fs::read_to_string(path).ok()?;
|
||||
chrono::DateTime::parse_from_rfc3339(raw.trim())
|
||||
.ok()
|
||||
.map(|t| t.with_timezone(&chrono::Utc))
|
||||
}
|
||||
|
||||
/// Read the first-observed instant for a pin, creating the marker if this is
|
||||
/// the first sighting. Returns `None` when the clock has not started yet.
|
||||
///
|
||||
|
||||
@@ -13,43 +13,6 @@ fn corrupt_marker_for(file_path: &Path) -> PathBuf {
|
||||
file_path.with_extension("json.corrupt")
|
||||
}
|
||||
|
||||
/// `<data_dir>/triple-c/projects.json.corrupt`, whether or not it exists.
|
||||
pub fn corrupt_marker_path() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| corrupt_marker_for(&d.join("triple-c").join("projects.json")))
|
||||
}
|
||||
|
||||
/// When this data directory last loaded a `projects.json` it could not parse,
|
||||
/// as the RFC3339 instant recorded in the marker.
|
||||
///
|
||||
/// ## Why this outlives the load that wrote it
|
||||
///
|
||||
/// A corrupt load is *recoverable for the app* — the list starts empty and
|
||||
/// everything keeps working — and that recovery is precisely what makes it
|
||||
/// dangerous for anything that reasons about which projects exist. The
|
||||
/// in-memory symptom does not survive: the first [`ProjectsStore::save`] after
|
||||
/// the failure, which is as little as starting one project (`update_status`),
|
||||
/// writes `[{that one project}]` over the file. From then on `projects.json`
|
||||
/// parses, holds one id, and looks exactly like a user with one project — while
|
||||
/// every *other* project's home and config volume is on the daemon claimed by
|
||||
/// nobody.
|
||||
///
|
||||
/// The guard in `project_store_trust` keyed on "the list is empty and the file
|
||||
/// exists", which that write silently ends. So the fact is recorded on disk
|
||||
/// instead of inferred from the list's shape, and it is **sticky**: nothing in
|
||||
/// this app clears it, because nothing in this app can reconstruct what the
|
||||
/// unreadable file held. The refusal names the marker so a user who has
|
||||
/// restored their list — or accepted the loss — can delete it deliberately.
|
||||
pub fn corrupt_since() -> Option<String> {
|
||||
let raw = fs::read_to_string(corrupt_marker_path()?).ok()?;
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
// The marker's presence is the signal; an empty one still means a
|
||||
// corrupt load happened, it just cannot say when.
|
||||
return Some("an unknown time".to_string());
|
||||
}
|
||||
Some(trimmed.lines().next().unwrap_or(trimmed).to_string())
|
||||
}
|
||||
|
||||
/// Keep the bytes of an unparseable `projects.json`, and record that it
|
||||
/// happened.
|
||||
///
|
||||
@@ -76,15 +39,21 @@ fn record_corrupt_load(file_path: &Path, now: &chrono::DateTime<chrono::Utc>) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sticky, and written even though nothing in the app reads it back on this
|
||||
// branch: the Disk panel's `project_store_trust` was the reader and went to
|
||||
// `hold/disk-and-dragout`. The marker stays because it is the only durable
|
||||
// record that a project list was lost — the in-memory symptom does not
|
||||
// survive the next save — and because re-deriving *when* it happened is
|
||||
// impossible after the fact.
|
||||
let marker = corrupt_marker_for(file_path);
|
||||
if marker.exists() {
|
||||
// Sticky: the *first* corruption is the one that dates the loss.
|
||||
// The *first* corruption is the one that dates the loss.
|
||||
return;
|
||||
}
|
||||
if let Err(e) = fs::write(&marker, now.to_rfc3339()) {
|
||||
log::error!(
|
||||
"Could not record the corrupt projects.json load at {}: {} — orphan detection will \
|
||||
not know the project list is incomplete",
|
||||
"Could not record the corrupt projects.json load at {}: {} — nothing will be able to \
|
||||
tell later that the project list was incomplete",
|
||||
marker.display(),
|
||||
e
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user