diff --git a/src/apk.rs b/src/apk.rs index 2665de4..42b3ab9 100644 --- a/src/apk.rs +++ b/src/apk.rs @@ -1002,15 +1002,21 @@ pub fn storage_status(home: &Path) -> Result { let contexts = config.contexts.unwrap_or_else(|| home.join(".local/share/lepton/contexts")); let db = load(home)?; let mut context_bytes = 0u64; + let mut contexts_incomplete = false; for id in &db.owned_contexts { - if safe(id) { context_bytes = context_bytes.saturating_add(crate::storage_location::bytes(&contexts.join(id))?); } + if safe(id) { + let (size, incomplete) = crate::storage_location::display_bytes(&contexts.join(id))?; + context_bytes = context_bytes.saturating_add(size); + contexts_incomplete |= incomplete; + } } + let (apk_bytes, apk_incomplete) = crate::storage_location::display_bytes(&apk)?; let journal = fs::read(storage_journal_path(home)).ok().and_then(|b|serde_json::from_slice::(&b).ok()); let committed = journal.as_ref().is_some_and(|v| v["target"] == json!(if v["kind"] == "apk" { &apk } else { &contexts })); if committed { let _ = fs::remove_file(storage_journal_path(home)); } - Ok(json!({"apk":{"path":apk,"bytes":crate::storage_location::bytes(&apk)?},"contexts":{"path":contexts,"bytes":context_bytes},"interrupted":if committed {None} else {journal}})) + Ok(json!({"apk":{"path":apk,"bytes":apk_bytes,"sizeIncomplete":apk_incomplete},"contexts":{"path":contexts,"bytes":context_bytes,"sizeIncomplete":contexts_incomplete},"interrupted":if committed {None} else {journal}})) } pub fn migrate_storage(home: &Path, kind: &str, target: &Path, cancel: Cancellation, progress: Arc) -> Result { ensure!(matches!(kind, "apk" | "contexts"), "无效存储类型"); @@ -1075,11 +1081,11 @@ pub fn migrate_storage(home: &Path, kind: &str, target: &Path, cancel: Cancellat progress(json!({"step":"cleaning"})); let mut warnings = Vec::new(); if kind == "apk" { - if let Err(error) = fs::remove_dir_all(&source) { warnings.push(error.to_string()); } + if let Err(error) = crate::storage_location::remove_tree(&source) { warnings.push(error.to_string()); } } else { for id in &db.owned_contexts { if safe(id) && source.join(id).exists() { - if let Err(error) = fs::remove_dir_all(source.join(id)) { warnings.push(error.to_string()); } + if let Err(error) = crate::storage_location::remove_tree(&source.join(id)) { warnings.push(error.to_string()); } } } } @@ -1101,10 +1107,18 @@ mod storage_migration_tests { fs::create_dir(&home).unwrap(); let original_apk = init(&home).unwrap(); fs::write(original_apk.join("apks/example.apk"), b"apk").unwrap(); + let apk_work = original_apk.join("recovery/old/baked/app_workdir/work"); + fs::create_dir_all(&apk_work).unwrap(); + fs::write(apk_work.join("overlay-state"), b"private").unwrap(); + fs::set_permissions(&apk_work, fs::Permissions::from_mode(0o000)).unwrap(); let original_contexts = contexts_root(&home); let baked = original_contexts.join("managed/baked/data_overlay/system"); fs::create_dir_all(&baked).unwrap(); fs::write(baked.join("packages.xml"), b"").unwrap(); + let context_work = original_contexts.join("managed/baked/data_workdir/work"); + fs::create_dir_all(&context_work).unwrap(); + fs::write(context_work.join("overlay-state"), b"private").unwrap(); + fs::set_permissions(&context_work, fs::Permissions::from_mode(0o000)).unwrap(); let external = original_contexts.join("external/baked/data_overlay/system"); fs::create_dir_all(&external).unwrap(); fs::write(external.join("packages.xml"), b"").unwrap(); @@ -1115,6 +1129,8 @@ mod storage_migration_tests { migrate_storage(&home,"apk",&apk_target,Cancellation::default(),Arc::new(|_|{})).unwrap(); assert_eq!(root(&home), apk_target); assert_eq!(fs::read(root(&home).join("apks/example.apk")).unwrap(), b"apk"); + assert!(!original_apk.exists()); + assert_eq!(fs::metadata(apk_target.join("recovery/old/baked/app_workdir/work")).unwrap().permissions().mode() & 0o777, 0); assert!(original_contexts.join("managed/baked").is_dir()); let context_target = temp.path().join("android-new"); migrate_storage(&home,"contexts",&context_target,Cancellation::default(),Arc::new(|_|{})).unwrap(); @@ -1126,6 +1142,8 @@ mod storage_migration_tests { let found = containers(&home,&load(&home).unwrap()); assert!(found.iter().any(|c|c.id=="managed" && c.baked.starts_with(&context_target))); assert!(found.iter().any(|c|c.id=="external" && c.baked.starts_with(&original_contexts))); + fs::set_permissions(apk_target.join("recovery/old/baked/app_workdir/work"), fs::Permissions::from_mode(0o700)).unwrap(); + fs::set_permissions(context_target.join("managed/baked/data_workdir/work"), fs::Permissions::from_mode(0o700)).unwrap(); } #[test] fn interrupted_apk_copy_resumes_without_touching_original() { @@ -1145,6 +1163,28 @@ mod storage_migration_tests { assert_eq!(fs::read(target.join("apks/keep.apk")).unwrap(),b"saved apk"); assert!(!storage_journal_path(&home).exists()); } + #[test] + fn storage_status_survives_unreadable_overlay_workdirs() { + if unsafe { libc::geteuid() } == 0 { return; } + let _serial = TEST_SERIAL.lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + fs::create_dir(&home).unwrap(); + let apk_root = init(&home).unwrap(); + let apk_work = apk_root.join("recovery/old/baked/app_workdir/work"); + fs::create_dir_all(&apk_work).unwrap(); + let context_work = contexts_root(&home).join("managed/baked/data_workdir/work"); + fs::create_dir_all(&context_work).unwrap(); + let mut db = load(&home).unwrap(); + db.owned_contexts.push("managed".into()); + save(&home, &db).unwrap(); + for path in [&apk_work, &context_work] { fs::set_permissions(path, fs::Permissions::from_mode(0o000)).unwrap(); } + let status = storage_status(&home); + for path in [&apk_work, &context_work] { fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); } + let status = status.unwrap(); + assert_eq!(status["apk"]["sizeIncomplete"], true); + assert_eq!(status["contexts"]["sizeIncomplete"], true); + } } pub fn launcher(home: &Path) -> Vec { let Ok(db) = load(home) else { return vec![] }; diff --git a/src/storage_location.rs b/src/storage_location.rs index ada54ae..99b86a2 100644 --- a/src/storage_location.rs +++ b/src/storage_location.rs @@ -4,8 +4,12 @@ use sha2::{Digest, Sha256}; use std::{collections::BTreeMap, ffi::CString, fs, io::Read, os::unix::fs::{MetadataExt, FileTypeExt, symlink, PermissionsExt}, path::{Path, PathBuf}, process::Command}; pub fn bytes(path: &Path) -> Result { - if !path.exists() { return Ok(0); } - let root = if fs::symlink_metadata(path)?.file_type().is_symlink() { path.canonicalize()? } else { path.to_path_buf() }; + let meta = match fs::symlink_metadata(path) { + Ok(meta) => meta, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(error.into()), + }; + let root = if meta.file_type().is_symlink() { path.canonicalize()? } else { path.to_path_buf() }; let mut total = 0u64; fn walk(path: &Path, total: &mut u64) -> Result<()> { let m = fs::symlink_metadata(path)?; @@ -19,6 +23,44 @@ pub fn bytes(path: &Path) -> Result { Ok(total) } +/// The settings page can show a lower bound when a container contains +/// root-owned overlay work directories. Migration still uses strict `bytes`. +pub fn display_bytes(path: &Path) -> Result<(u64, bool)> { + let meta = match fs::symlink_metadata(path) { + Ok(meta) => meta, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok((0, false)), + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return Ok((0, true)), + Err(error) => return Err(error.into()), + }; + let root = if meta.file_type().is_symlink() { + match path.canonicalize() { + Ok(root) => root, + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return Ok((0, true)), + Err(error) => return Err(error.into()), + } + } else { path.to_path_buf() }; + fn walk(path: &Path, total: &mut u64, incomplete: &mut bool) -> Result<()> { + let meta = match fs::symlink_metadata(path) { + Ok(meta) => meta, + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { *incomplete = true; return Ok(()); } + Err(error) => return Err(error.into()), + }; + if meta.is_file() { *total = total.saturating_add(meta.len()); } + if meta.is_dir() && !meta.file_type().is_symlink() { + let entries = match fs::read_dir(path) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { *incomplete = true; return Ok(()); } + Err(error) => return Err(error.into()), + }; + for entry in entries { walk(&entry?.path(), total, incomplete)?; } + } + Ok(()) + } + let (mut total, mut incomplete) = (0, false); + walk(&root, &mut total, &mut incomplete)?; + Ok((total, incomplete)) +} + fn inventory(path: &Path) -> Result> { let mut files = BTreeMap::new(); fn walk(root: &Path, rel: &Path, files: &mut BTreeMap) -> Result<()> { @@ -46,7 +88,10 @@ fn inventory(path: &Path) -> Result> { Ok(files) } pub fn verified_equal(source: &Path, target: &Path) -> Result { - Ok(source.is_dir() && target.is_dir() && inventory(source)? == inventory(target)?) + if !source.is_dir() || !target.is_dir() { return Ok(false); } + let _source_readable = ReadableDirs::prepare(source)?; + let _target_readable = ReadableDirs::prepare(target)?; + Ok(inventory(source)? == inventory(target)?) } fn selected_source(source: &Path, name: &str, follow_root_links: bool) -> Result { let path = source.join(name); @@ -120,8 +165,55 @@ fn prepare_space(parent: &Path, needed: u64) -> Result<()> { result } +struct ReadableDirs { + root: PathBuf, + changed: Vec<(PathBuf, u32)>, +} +impl ReadableDirs { + fn prepare(root: &Path) -> Result { + Self::prepare_with(root, 0o500) + } + fn prepare_with(root: &Path, required: u32) -> Result { + let mut guard = Self { root: root.to_path_buf(), changed: Vec::new() }; + if root.exists() { guard.walk(root, required)?; } + Ok(guard) + } + fn walk(&mut self, path: &Path, required: u32) -> Result<()> { + let meta = fs::symlink_metadata(path)?; + if !meta.is_dir() || meta.file_type().is_symlink() { return Ok(()); } + let mode = meta.permissions().mode() & 0o7777; + if mode & required != required { + ensure!(meta.uid() == unsafe { libc::geteuid() } || unsafe { libc::geteuid() } == 0, + "存储目录权限不足,无法迁移:{}", path.display()); + fs::set_permissions(path, fs::Permissions::from_mode(mode | required))?; + self.changed.push((path.strip_prefix(&self.root)?.to_path_buf(), mode)); + } + for entry in fs::read_dir(path)? { self.walk(&entry?.path(), required)?; } + Ok(()) + } + fn restore_copy(&self, copy: &Path) -> Result<()> { + for (relative, mode) in self.changed.iter().rev() { + fs::set_permissions(copy.join(relative), fs::Permissions::from_mode(*mode))?; + } + Ok(()) + } +} +impl Drop for ReadableDirs { + fn drop(&mut self) { + for (relative, mode) in self.changed.iter().rev() { + let _ = fs::set_permissions(self.root.join(relative), fs::Permissions::from_mode(*mode)); + } + } +} +pub fn remove_tree(path: &Path) -> Result<()> { + let _readable = ReadableDirs::prepare_with(path, 0o700)?; + fs::remove_dir_all(path)?; + Ok(()) +} + pub fn copy_verified(source: &Path, target: &Path, mut progress: impl FnMut(&str)) -> Result<()> { let target = validate_target(source, target, false)?; + let readable = ReadableDirs::prepare(source)?; let parent = target.parent().unwrap(); prepare_space(parent, bytes(source)?)?; let stage = parent.join(format!(".framely-storage-{:016x}", rand::random::())); @@ -137,6 +229,7 @@ pub fn copy_verified(source: &Path, target: &Path, mut progress: impl FnMut(&str let source_files = if source.exists() { inventory(source)? } else { BTreeMap::new() }; let target_files = inventory(&stage)?; ensure!(source_files == target_files || (!source.exists() && target_files.len() == 1), "迁移后的文件校验失败,仍使用原位置"); + readable.restore_copy(&stage)?; progress("switching"); if target.exists() { fs::remove_dir(&target)?; } fs::rename(&stage, &target)?; @@ -151,7 +244,12 @@ pub fn copy_selected(source: &Path, target: &Path, names: &[String], follow_root let target = validate_target(source, target, false)?; let parent = target.parent().unwrap(); let mut needed = 0u64; - for name in names { needed = needed.saturating_add(bytes(&selected_source(source, name, follow_root_links)?)?); } + let mut readable = Vec::new(); + for name in names { + let from = selected_source(source, name, follow_root_links)?; + readable.push((name.clone(), ReadableDirs::prepare(&from)?)); + needed = needed.saturating_add(bytes(&from)?); + } prepare_space(parent, needed)?; let stage = parent.join(format!(".framely-storage-{:016x}", rand::random::())); fs::create_dir(&stage)?; @@ -170,6 +268,7 @@ pub fn copy_selected(source: &Path, target: &Path, names: &[String], follow_root let from = selected_source(source, name, follow_root_links)?; if from.exists() { ensure!(inventory(&from)? == inventory(&stage.join(name))?, "容器迁移校验失败:{name}"); } } + for (name, guard) in &readable { if stage.join(name).exists() { guard.restore_copy(&stage.join(name))?; } } progress("switching"); if target.exists() { fs::remove_dir(&target)?; } fs::rename(&stage, &target)?; @@ -211,9 +310,16 @@ mod tests { fs::create_dir(source.join(name)).unwrap(); fs::write(source.join(name).join("state"), name).unwrap(); } + let work = source.join("managed/app_workdir/work"); + fs::create_dir_all(&work).unwrap(); + fs::write(work.join("overlay-state"), b"private").unwrap(); + fs::set_permissions(&work, fs::Permissions::from_mode(0o000)).unwrap(); let target = temp.path().join("android"); copy_selected(&source, &target, &["managed".into()], false, |_| {}).unwrap(); assert_eq!(fs::read_to_string(target.join("managed/state")).unwrap(), "managed"); + assert!(verified_selected(&source, &target, &["managed".into()], false).unwrap()); + fs::set_permissions(&work, fs::Permissions::from_mode(0o700)).unwrap(); + fs::set_permissions(target.join("managed/app_workdir/work"), fs::Permissions::from_mode(0o700)).unwrap(); assert!(!target.join("external").exists()); assert!(source.join("external/state").is_file()); } @@ -234,4 +340,28 @@ mod tests { assert_eq!(fs::read(target.join("plugins/manifest.json")).unwrap(),b"plugin"); assert!(verified_selected(&source,&target,&["plugins".into()],true).unwrap()); } + #[test] + fn display_size_marks_unreadable_overlay_work_without_hiding_migration_error() { + if unsafe { libc::geteuid() } == 0 { return; } + let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); + fs::write(temp.path().join("visible"), b"saved").unwrap(); + let work = temp.path().join("app_workdir/work"); + fs::create_dir_all(&work).unwrap(); + fs::write(work.join("overlay-state"), b"private").unwrap(); + fs::set_permissions(&work, fs::Permissions::from_mode(0o000)).unwrap(); + let shown = display_bytes(temp.path()); + let strict = bytes(temp.path()); + assert_eq!(shown.unwrap(), (5, true)); + assert!(strict.is_err()); + let target = temp.path().parent().unwrap().join(format!("framely-storage-test-{:016x}", rand::random::())); + copy_verified(temp.path(), &target, |_| {}).unwrap(); + assert_eq!(fs::metadata(&work).unwrap().permissions().mode() & 0o777, 0); + let copied_work = target.join("app_workdir/work"); + assert_eq!(fs::metadata(&copied_work).unwrap().permissions().mode() & 0o777, 0); + assert!(verified_equal(temp.path(), &target).unwrap()); + fs::set_permissions(&work, fs::Permissions::from_mode(0o700)).unwrap(); + fs::set_permissions(&copied_work, fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!(fs::read(copied_work.join("overlay-state")).unwrap(), b"private"); + fs::remove_dir_all(target).unwrap(); + } } diff --git a/ui/src/storage-settings.tsx b/ui/src/storage-settings.tsx index d0180f9..c5316ce 100644 --- a/ui/src/storage-settings.tsx +++ b/ui/src/storage-settings.tsx @@ -6,7 +6,7 @@ import {t} from './i18n'; import './storage-settings.css'; type Kind='plugin'|'apk'|'contexts'; -type Location={path:string;bytes:number;migrating?:boolean;available?:boolean;interrupted?:string|null}; +type Location={path:string;bytes:number;sizeIncomplete?:boolean;migrating?:boolean;available?:boolean;interrupted?:string|null}; type Places={plugin:Location;apk:Location;contexts:Location}; type Job={phase:string;step?:string;error?:string;result?:{path:string;cleanupWarnings?:string[]}}; type Listing={path:string;parent:string|null;roots:{name:string;path:string}[];entries:{name:string;path:string;directory:boolean}[]}; @@ -30,5 +30,5 @@ export function StorageSettings(){ useEffect(()=>{if(!job)return;let alive=true;const poll=async()=>{try{const value=await api(job.kind==='plugin'?'storage.plugin.job':'job.status',{job:job.id});if(!alive)return;setStep(value.step??value.phase);if(value.phase==='done'){setJob(null);setActive(null);setNotice(value.result?.cleanupWarnings?.length?t('迁移完成;旧目录清理未完成,请检查原位置。'):t('迁移完成'));setPaths(v=>({...v,[job.kind]:value.result?.path??v[job.kind]}));await refresh();}else if(value.phase==='failed'||value.phase==='cancelled'){setJob(null);setActive(null);setError(value.error??t('迁移失败'));await refresh();}}catch(e){if(alive)setError(String(e));}};void poll();const timer=setInterval(()=>void poll(),500);return()=>{alive=false;clearInterval(timer);};},[job]); async function migrate(kind:Kind){setError('');setNotice('');setActive(kind);try{const result=await api<{job:string}>(kind==='plugin'?'storage.plugin.start':'apk.storage.start',kind==='plugin'?{path:paths[kind]}:{kind,path:paths[kind]});setJob({kind,id:result.job});setStep('queued');}catch(e){setError(String(e));setActive(null);}} const descriptions:Record={queued:'排队中',loading:'准备中',stopping:'停止相关程序',copying:'复制数据',verifying:'核对文件',switching:'切换目录',restarting:'恢复运行',cleaning:'清理旧目录'}; - return

{t('存储')}

{t('分别管理插件、APK 文件和安卓容器的数据位置。')}

{t('迁移会暂时停止相关程序;完成前原位置仍可用于恢复。目标目录须为空,并支持 Linux 文件权限。')}

{error&&

{error}

}{notice&&

{notice}

}{places&&(['plugin','apk','contexts'] as const).map(kind=>{const place=places[kind],busy=!!job||!!active;return

{t(labels[kind].title)}

{t(labels[kind].description)}

{size(place.bytes)}
{t('当前位置')}{place.path}
{place.available===false&&

{t('当前存储目录不可用,请先连接存储设备。')}

}{place.interrupted&&

{t('上次迁移未完成,原目录仍在使用。可重试原目标或选择新的空目录。原副本不会自动删除:')}{place.interrupted}

}
;})}{picker&&setPicker(null)} choose={parent=>setPaths(v=>({...v,[picker]:parent.replace(/\/$/,'')+'/'+labels[picker].folder.replace(/ /g,'-')}))}/>}
; + return

{t('存储')}

{t('分别管理插件、APK 文件和安卓容器的数据位置。')}

{t('迁移会暂时停止相关程序;完成前原位置仍可用于恢复。目标目录须为空,并支持 Linux 文件权限。')}

{error&&

{error}

}{notice&&

{notice}

}{places&&(['plugin','apk','contexts'] as const).map(kind=>{const place=places[kind],busy=!!job||!!active;return

{t(labels[kind].title)}

{t(labels[kind].description)}

{place.sizeIncomplete?'≥ ':''}{size(place.bytes)}
{t('当前位置')}{place.path}
{place.available===false&&

{t('当前存储目录不可用,请先连接存储设备。')}

}{place.interrupted&&

{t('上次迁移未完成,原目录仍在使用。可重试原目标或选择新的空目录。原副本不会自动删除:')}{place.interrupted}

}
;})}{picker&&setPicker(null)} choose={parent=>setPaths(v=>({...v,[picker]:parent.replace(/\/$/,'')+'/'+labels[picker].folder.replace(/ /g,'-')}))}/>}
; }