Handle restricted overlay directories in storage settings and migration

This commit is contained in:
toorux committed 2026-10-11 11:07:08 +08:00
1 parent 6cb052f732
commit c647aa28a0
3 files changed
+180 -10

No files matched your search

+44 -4
View File
@@ -1002,15 +1002,21 @@ pub fn storage_status(home: &Path) -> Result<Value> {
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::<Value>(&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<dyn Fn(Value) + Send + Sync>) -> Result<Value> {
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"<packages/>").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"<packages/>").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<Value> {
let Ok(db) = load(home) else { return vec![] };
+134 -4
View File
@@ -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<u64> {
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<u64> {
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<BTreeMap<PathBuf, String>> {
let mut files = BTreeMap::new();
fn walk(root: &Path, rel: &Path, files: &mut BTreeMap<PathBuf, String>) -> Result<()> {
@@ -46,7 +88,10 @@ fn inventory(path: &Path) -> Result<BTreeMap<PathBuf, String>> {
Ok(files)
}
pub fn verified_equal(source: &Path, target: &Path) -> Result<bool> {
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<PathBuf> {
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> {
Self::prepare_with(root, 0o500)
}
fn prepare_with(root: &Path, required: u32) -> Result<Self> {
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::<u64>()));
@@ -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::<u64>()));
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::<u64>()));
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();
}
}
+2 -2
View File
@@ -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>(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<string,string>={queued:'排队中',loading:'准备中',stopping:'停止相关程序',copying:'复制数据',verifying:'核对文件',switching:'切换目录',restarting:'恢复运行',cleaning:'清理旧目录'};
return <div className="storage-settings"><div className="list-heading"><div><h1>{t('存储')}</h1><p>{t('分别管理插件、APK 文件和安卓容器的数据位置。')}</p></div></div><p className="storage-intro">{t('迁移会暂时停止相关程序;完成前原位置仍可用于恢复。目标目录须为空,并支持 Linux 文件权限。')}</p>{error&&<p className="error" role="alert">{error}</p>}{notice&&<p className="storage-notice" role="status">{notice}</p>}{places&&(['plugin','apk','contexts'] as const).map(kind=>{const place=places[kind],busy=!!job||!!active;return <section className="settings-card storage-card" key={kind}><div className="storage-card-heading"><div className="storage-card-icon"><IconDeviceFloppy size={24} stroke={1.6}/></div><div><h2>{t(labels[kind].title)}</h2><p>{t(labels[kind].description)}</p></div><strong>{size(place.bytes)}</strong></div><div className="storage-current"><span>{t('当前位置')}</span><code title={place.path}>{place.path}</code></div>{place.available===false&&<p className="storage-interrupted" role="status">{t('当前存储目录不可用,请先连接存储设备。')}</p>}{place.interrupted&&<p className="storage-interrupted" role="status">{t('上次迁移未完成,原目录仍在使用。可重试原目标或选择新的空目录。原副本不会自动删除:')}<code>{place.interrupted}</code></p>}<label className="storage-target"><span>{t('目标目录')}</span><div><input aria-label={`${t(labels[kind].title)} ${t('目标目录')}`} value={paths[kind]} disabled={busy} onChange={e=>setPaths(v=>({...v,[kind]:e.target.value}))} spellCheck={false}/><button disabled={busy} onClick={()=>setPicker(kind)}><IconFolderOpen size={18}/>{t('浏览')}</button></div></label><div className="storage-actions"><button className="primary" disabled={busy||place.available===false||!paths[kind]||paths[kind]===place.path} onClick={()=>void migrate(kind)}>{active===kind?<><IconLoader2 size={18}/>{t(descriptions[step]??step)}</>:t('迁移到此目录')}</button></div></section>;})}{picker&&<FolderBrowser close={()=>setPicker(null)} choose={parent=>setPaths(v=>({...v,[picker]:parent.replace(/\/$/,'')+'/'+labels[picker].folder.replace(/ /g,'-')}))}/>}</div>;
return <div className="storage-settings"><div className="list-heading"><div><h1>{t('存储')}</h1><p>{t('分别管理插件、APK 文件和安卓容器的数据位置。')}</p></div></div><p className="storage-intro">{t('迁移会暂时停止相关程序;完成前原位置仍可用于恢复。目标目录须为空,并支持 Linux 文件权限。')}</p>{error&&<p className="error" role="alert">{error}</p>}{notice&&<p className="storage-notice" role="status">{notice}</p>}{places&&(['plugin','apk','contexts'] as const).map(kind=>{const place=places[kind],busy=!!job||!!active;return <section className="settings-card storage-card" key={kind}><div className="storage-card-heading"><div className="storage-card-icon"><IconDeviceFloppy size={24} stroke={1.6}/></div><div><h2>{t(labels[kind].title)}</h2><p>{t(labels[kind].description)}</p></div><strong title={place.sizeIncomplete?t('部分目录无法读取,显示可统计的最小占用'):undefined}>{place.sizeIncomplete?'≥ ':''}{size(place.bytes)}</strong></div><div className="storage-current"><span>{t('当前位置')}</span><code title={place.path}>{place.path}</code></div>{place.available===false&&<p className="storage-interrupted" role="status">{t('当前存储目录不可用,请先连接存储设备。')}</p>}{place.interrupted&&<p className="storage-interrupted" role="status">{t('上次迁移未完成,原目录仍在使用。可重试原目标或选择新的空目录。原副本不会自动删除:')}<code>{place.interrupted}</code></p>}<label className="storage-target"><span>{t('目标目录')}</span><div><input aria-label={`${t(labels[kind].title)} ${t('目标目录')}`} value={paths[kind]} disabled={busy} onChange={e=>setPaths(v=>({...v,[kind]:e.target.value}))} spellCheck={false}/><button disabled={busy} onClick={()=>setPicker(kind)}><IconFolderOpen size={18}/>{t('浏览')}</button></div></label><div className="storage-actions"><button className="primary" disabled={busy||place.available===false||!paths[kind]||paths[kind]===place.path} onClick={()=>void migrate(kind)}>{active===kind?<><IconLoader2 size={18}/>{t(descriptions[step]??step)}</>:t('迁移到此目录')}</button></div></section>;})}{picker&&<FolderBrowser close={()=>setPicker(null)} choose={parent=>setPaths(v=>({...v,[picker]:parent.replace(/\/$/,'')+'/'+labels[picker].folder.replace(/ /g,'-')}))}/>}</div>;
}