use bollard::container::{DownloadFromContainerOptions, LogOutput, UploadToContainerOptions}; use bollard::exec::{CreateExecOptions, StartExecResults}; use futures_util::StreamExt; use serde::Serialize; use tauri::State; use crate::docker::client::get_docker; use crate::docker::exec::exec_oneshot; use crate::AppState; #[derive(Debug, Serialize)] pub struct FileEntry { pub name: String, pub path: String, pub is_directory: bool, pub size: u64, pub modified: String, pub permissions: String, } #[tauri::command] pub async fn list_container_files( project_id: String, path: String, state: State<'_, AppState>, ) -> Result, String> { let project = state .projects_store .get(&project_id) .ok_or_else(|| format!("Project {} not found", project_id))?; let container_id = project .container_id .as_ref() .ok_or_else(|| "Container not running".to_string())?; let cmd = vec![ "find".to_string(), path.clone(), "-mindepth".to_string(), "1".to_string(), "-maxdepth".to_string(), "1".to_string(), "-printf".to_string(), "%f\t%y\t%s\t%T@\t%m\n".to_string(), ]; let output = exec_oneshot(container_id, cmd).await?; let mut entries: Vec = output .lines() .filter(|line| !line.trim().is_empty()) .filter_map(|line| { let parts: Vec<&str> = line.split('\t').collect(); if parts.len() < 5 { return None; } let name = parts[0].to_string(); let is_directory = parts[1] == "d"; let size = parts[2].parse::().unwrap_or(0); let modified_epoch = parts[3].parse::().unwrap_or(0.0); let permissions = parts[4].to_string(); // Convert epoch to ISO-ish string let modified = { let secs = modified_epoch as i64; let dt = chrono::DateTime::from_timestamp(secs, 0) .unwrap_or_default(); dt.format("%Y-%m-%d %H:%M:%S").to_string() }; let entry_path = if path.ends_with('/') { format!("{}{}", path, name) } else { format!("{}/{}", path, name) }; Some(FileEntry { name, path: entry_path, is_directory, size, modified, permissions, }) }) .collect(); // Sort: directories first, then alphabetical entries.sort_by(|a, b| { b.is_directory .cmp(&a.is_directory) .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) }); Ok(entries) } #[tauri::command] pub async fn download_container_file( project_id: String, container_path: String, host_path: String, state: State<'_, AppState>, ) -> Result<(), String> { let project = state .projects_store .get(&project_id) .ok_or_else(|| format!("Project {} not found", project_id))?; let container_id = project .container_id .as_ref() .ok_or_else(|| "Container not running".to_string())?; let docker = get_docker()?; let mut stream = docker.download_from_container( container_id, Some(DownloadFromContainerOptions { path: container_path.clone(), }), ); let mut tar_bytes = Vec::new(); while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|e| format!("Failed to download file: {}", e))?; tar_bytes.extend_from_slice(&chunk); } // Extract single file from tar archive let mut archive = tar::Archive::new(&tar_bytes[..]); let mut found = false; for entry in archive .entries() .map_err(|e| format!("Failed to read tar entries: {}", e))? { let mut entry = entry.map_err(|e| format!("Failed to read tar entry: {}", e))?; let mut contents = Vec::new(); std::io::Read::read_to_end(&mut entry, &mut contents) .map_err(|e| format!("Failed to read file contents: {}", e))?; std::fs::write(&host_path, &contents) .map_err(|e| format!("Failed to write file to host: {}", e))?; found = true; break; } if !found { return Err("File not found in tar archive".to_string()); } Ok(()) } /// Create a `.tar.gz` backup of the container and stream it to a host file. /// The archive contains: /// - the workspace (default /workspace), minus regenerable build artifacts /// (node_modules, target), under `workspace/`, and /// - a sanitized copy of the home config under `home-claude/`: ~/.claude.json /// with secret-bearing keys removed (mcpServers/settings kept) and ~/.claude/ /// minus the OAuth `.credentials.json`, so MCP servers, settings and skills /// set up via Claude Code survive a Reset. /// `.git` is kept in full so the backup faithfully preserves git history, /// including unpushed commits. Build + gzip happen inside the container so a /// large workspace isn't streamed in full. The container must be RUNNING (the /// backup runs via `docker exec`). Returns the number of bytes written. #[tauri::command] pub async fn download_container_backup( project_id: String, host_path: String, container_path: Option, state: State<'_, AppState>, ) -> Result { 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(|| "No container exists for this project yet — start it first".to_string())?; let docker = get_docker()?; // The backup runs inside the container via `docker exec`, which requires it // to be running. Fail with a clear message rather than a raw Docker error. let running = docker .inspect_container(container_id, None) .await .ok() .and_then(|info| info.state) .and_then(|s| s.running) .unwrap_or(false); if !running { return Err("Start the project before backing up — the backup runs inside the running container.".to_string()); } let path = container_path.unwrap_or_else(|| "/workspace".to_string()); // Stage a sanitized home config, then tar+gzip workspace + staged config to // stdout. mktemp/jq output go nowhere near stdout, so the only thing the // exec emits on stdout is the archive itself. --ignore-failed-read keeps a // transient unreadable file from aborting the whole backup. If jq can't // parse ~/.claude.json we substitute an empty object — never the raw file — // so secrets can't leak through the sanitization fallback. // The `--transform` nests the workspace under `workspace/` (parallel to // `home-claude/`) so an extracted archive has both clearly labeled instead // of scattering the workspace files into the extraction dir. `flags=rh` // rewrites regular member names AND hardlink target names (so an intra- // workspace hardlink pair still resolves on extract) while leaving symlink // targets untouched (rewriting those would corrupt relative/absolute links). let script = r#"set -e STAGE=$(mktemp -d) trap 'rm -rf "$STAGE"' EXIT mkdir -p "$STAGE/home-claude" if [ -f "$HOME/.claude.json" ]; then if ! jq 'del(.primaryApiKey, .oauthAccount, .customApiKeyResponses)' "$HOME/.claude.json" \ > "$STAGE/home-claude/.claude.json" 2>/dev/null; then echo "warning: could not sanitize .claude.json; omitting it from backup" >&2 printf '{}' > "$STAGE/home-claude/.claude.json" fi fi if [ -d "$HOME/.claude" ]; then cp -a "$HOME/.claude" "$STAGE/home-claude/.claude" 2>/dev/null || true rm -f "$STAGE/home-claude/.claude/.credentials.json" fi tar czf - --ignore-failed-read \ --exclude='*/node_modules' --exclude='*/target' \ --transform='flags=rh;s,^\./,workspace/,' \ -C "$TC_BACKUP_SRC" . \ -C "$STAGE" home-claude"#; let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()]; let exec = docker .create_exec( container_id, CreateExecOptions { attach_stdout: Some(true), attach_stderr: Some(true), cmd: Some(cmd), env: Some(vec![ "HOME=/home/claude".to_string(), format!("TC_BACKUP_SRC={}", path), ]), user: Some("claude".to_string()), ..Default::default() }, ) .await .map_err(|e| format!("Failed to create backup exec: {}", e))?; let result = docker .start_exec(&exec.id, None) .await .map_err(|e| format!("Failed to start backup exec: {}", e))?; let mut output = match result { StartExecResults::Attached { output, .. } => output, StartExecResults::Detached => return Err("Backup exec started detached".to_string()), }; use tokio::io::AsyncWriteExt; let file = tokio::fs::File::create(&host_path) .await .map_err(|e| format!("Failed to create backup file: {}", e))?; let mut writer = tokio::io::BufWriter::new(file); let mut total: u64 = 0; let mut stderr_text = String::new(); let mut stream_err: Option = None; while let Some(msg) = output.next().await { match msg { Ok(LogOutput::StdOut { message }) => { if let Err(e) = writer.write_all(&message).await { stream_err = Some(format!("Failed to write backup file: {}", e)); break; } total += message.len() as u64; } Ok(LogOutput::StdErr { message }) => { stderr_text.push_str(&String::from_utf8_lossy(&message)); } Ok(_) => {} Err(e) => { stream_err = Some(format!("Backup stream error: {}", e)); break; } } } if stream_err.is_none() { if let Err(e) = writer.flush().await { stream_err = Some(format!("Failed to finalize backup file: {}", e)); } } drop(writer); // The tar pipeline can abort mid-stream (producing a truncated archive) and // still have sent bytes, so a non-zero exit must be treated as failure even // when `total > 0`. Poll until the exec actually reports finished so the // exit code is reliably populated; if it can't be determined we fall back to // the `total == 0` check below. let exit_code = crate::docker::exec::wait_for_exec_exit(&exec.id).await; if stream_err.is_none() && exit_code.is_some_and(|c| c != 0) { stream_err = Some(format!( "Backup command failed (exit {}){}", exit_code.unwrap_or(-1), if stderr_text.trim().is_empty() { String::new() } else { format!(": {}", stderr_text.trim()) } )); } if stream_err.is_none() && total == 0 { stream_err = Some(format!( "Backup produced no data{}", if stderr_text.trim().is_empty() { String::new() } else { format!(": {}", stderr_text.trim()) } )); } if let Some(err) = stream_err { // Don't leave a partial/corrupt archive behind. let _ = tokio::fs::remove_file(&host_path).await; return Err(err); } log::info!( "Wrote {} byte backup for project {} to {}", total, project_id, host_path ); Ok(total) } #[tauri::command] pub async fn upload_file_to_container( project_id: String, host_path: String, container_dir: String, state: State<'_, AppState>, ) -> Result<(), String> { let project = state .projects_store .get(&project_id) .ok_or_else(|| format!("Project {} not found", project_id))?; let container_id = project .container_id .as_ref() .ok_or_else(|| "Container not running".to_string())?; let docker = get_docker()?; let file_data = std::fs::read(&host_path) .map_err(|e| format!("Failed to read host file: {}", e))?; let file_name = std::path::Path::new(&host_path) .file_name() .ok_or_else(|| "Invalid file path".to_string())? .to_string_lossy() .to_string(); // Build tar archive in memory let mut tar_buf = Vec::new(); { let mut builder = tar::Builder::new(&mut tar_buf); let mut header = tar::Header::new_gnu(); header.set_size(file_data.len() as u64); header.set_mode(0o644); header.set_cksum(); builder .append_data(&mut header, &file_name, &file_data[..]) .map_err(|e| format!("Failed to create tar entry: {}", e))?; builder .finish() .map_err(|e| format!("Failed to finalize tar: {}", e))?; } docker .upload_to_container( container_id, Some(UploadToContainerOptions { path: container_dir, ..Default::default() }), tar_buf.into(), ) .await .map_err(|e| format!("Failed to upload file to container: {}", e))?; Ok(()) }