stage #4581 onto master (v0.51.556) — surface escape-target symlinks as display-only workspace rows
claw-io: symlinks whose target resolves outside the workspace root were silently dropped; now emitted with target_outside_workspace=True (display-only — safe_resolve_ws/open_anchored_fd still block navigation). Dedup-winner over #4573 (stronger tests). Co-authored-by: claw-io <claw-io@users.noreply.github.com>
This commit is contained in:
1 parent
4c45fc8a55
commit
1e18c336f1
8 files changed
+188
-28
No files matched your search
+9
-2
@@ -1220,11 +1220,16 @@ def list_dir(workspace: Path, rel: str='.'):
|
|||||||
return # target is under link_target — ancestor → cycle
|
return # target is under link_target — ancestor → cycle
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
# Hide symlinks that resolve outside the workspace (can never be opened).
|
# Tag symlinks whose resolved target escapes the workspace root.
|
||||||
|
# Previously silently dropped; now emitted with target_outside_workspace=True
|
||||||
|
# so the workspace tree can show the link exists (display-only — the
|
||||||
|
# read/list gate in safe_resolve_ws / open_anchored_fd still blocks
|
||||||
|
# navigation through it).
|
||||||
|
target_outside_workspace = False
|
||||||
try:
|
try:
|
||||||
link_target.relative_to(ws_resolved)
|
link_target.relative_to(ws_resolved)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return
|
target_outside_workspace = True
|
||||||
if _is_blocked_system_path(link_target):
|
if _is_blocked_system_path(link_target):
|
||||||
return
|
return
|
||||||
is_dir = link_target.is_dir()
|
is_dir = link_target.is_dir()
|
||||||
@@ -1238,6 +1243,7 @@ def list_dir(workspace: Path, rel: str='.'):
|
|||||||
'type': 'symlink',
|
'type': 'symlink',
|
||||||
'target': str(link_target),
|
'target': str(link_target),
|
||||||
'is_dir': is_dir,
|
'is_dir': is_dir,
|
||||||
|
'target_outside_workspace': target_outside_workspace,
|
||||||
'mtime_ns': mtime_ns,
|
'mtime_ns': mtime_ns,
|
||||||
}
|
}
|
||||||
if not is_dir:
|
if not is_dir:
|
||||||
@@ -1381,6 +1387,7 @@ def dir_signature(workspace: Path, rel: str = '.', entries: list[dict] | None =
|
|||||||
'size': entry.get('size'),
|
'size': entry.get('size'),
|
||||||
'mtime_ns': entry.get('mtime_ns'),
|
'mtime_ns': entry.get('mtime_ns'),
|
||||||
'target': entry.get('target'),
|
'target': entry.get('target'),
|
||||||
|
'target_outside_workspace': entry.get('target_outside_workspace'),
|
||||||
})
|
})
|
||||||
raw = json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
|
raw = json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
|
||||||
return hashlib.sha256(raw.encode('utf-8')).hexdigest()
|
return hashlib.sha256(raw.encode('utf-8')).hexdigest()
|
||||||
|
|||||||
@@ -443,6 +443,7 @@ const LOCALES = {
|
|||||||
downloading: (name) => `Downloading ${name}\u2026`,
|
downloading: (name) => `Downloading ${name}\u2026`,
|
||||||
double_click_rename: 'Double-click to rename',
|
double_click_rename: 'Double-click to rename',
|
||||||
symlink_link_to: 'Symlink → {target}',
|
symlink_link_to: 'Symlink → {target}',
|
||||||
|
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
|
||||||
session_rename_failed_no_row: 'Could not start rename — row not found.',
|
session_rename_failed_no_row: 'Could not start rename — row not found.',
|
||||||
renamed_to: 'Renamed to ',
|
renamed_to: 'Renamed to ',
|
||||||
rename_failed: 'Rename failed: ',
|
rename_failed: 'Rename failed: ',
|
||||||
@@ -1988,6 +1989,7 @@ const LOCALES = {
|
|||||||
downloading: (name) => `Scaricamento ${name}\u2026`,
|
downloading: (name) => `Scaricamento ${name}\u2026`,
|
||||||
double_click_rename: 'Doppio clic per rinominare',
|
double_click_rename: 'Doppio clic per rinominare',
|
||||||
symlink_link_to: 'Symlink → {target}',
|
symlink_link_to: 'Symlink → {target}',
|
||||||
|
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
|
||||||
session_rename_failed_no_row: 'Could not start rename — row not found.',
|
session_rename_failed_no_row: 'Could not start rename — row not found.',
|
||||||
renamed_to: 'Rinominato in ',
|
renamed_to: 'Rinominato in ',
|
||||||
rename_failed: 'Rinomina fallita: ',
|
rename_failed: 'Rinomina fallita: ',
|
||||||
@@ -3524,6 +3526,7 @@ const LOCALES = {
|
|||||||
downloading: (name) => `${name} をダウンロード中…`,
|
downloading: (name) => `${name} をダウンロード中…`,
|
||||||
double_click_rename: 'ダブルクリックで名前変更',
|
double_click_rename: 'ダブルクリックで名前変更',
|
||||||
symlink_link_to: 'Symlink → {target}',
|
symlink_link_to: 'Symlink → {target}',
|
||||||
|
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
|
||||||
session_rename_failed_no_row: '名前変更を開始できませんでした — 行が見つかりません。',
|
session_rename_failed_no_row: '名前変更を開始できませんでした — 行が見つかりません。',
|
||||||
renamed_to: '名前を変更: ',
|
renamed_to: '名前を変更: ',
|
||||||
rename_failed: '名前変更失敗: ',
|
rename_failed: '名前変更失敗: ',
|
||||||
@@ -4984,6 +4987,7 @@ const LOCALES = {
|
|||||||
downloading: (name) => `Скачиваю ${name}…`,
|
downloading: (name) => `Скачиваю ${name}…`,
|
||||||
double_click_rename: 'Дважды щёлкните, чтобы переименовать',
|
double_click_rename: 'Дважды щёлкните, чтобы переименовать',
|
||||||
symlink_link_to: 'Symlink → {target}',
|
symlink_link_to: 'Symlink → {target}',
|
||||||
|
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
|
||||||
session_rename_failed_no_row: 'Could not start rename — row not found.',
|
session_rename_failed_no_row: 'Could not start rename — row not found.',
|
||||||
renamed_to: 'Переименовано в ',
|
renamed_to: 'Переименовано в ',
|
||||||
rename_failed: 'Не удалось переименовать: ',
|
rename_failed: 'Не удалось переименовать: ',
|
||||||
@@ -6437,6 +6441,7 @@ const LOCALES = {
|
|||||||
downloading: (name) => `Descargando ${name}…`,
|
downloading: (name) => `Descargando ${name}…`,
|
||||||
double_click_rename: 'Haz doble clic para renombrar',
|
double_click_rename: 'Haz doble clic para renombrar',
|
||||||
symlink_link_to: 'Symlink → {target}',
|
symlink_link_to: 'Symlink → {target}',
|
||||||
|
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
|
||||||
session_rename_failed_no_row: 'Could not start rename — row not found.',
|
session_rename_failed_no_row: 'Could not start rename — row not found.',
|
||||||
renamed_to: 'Renombrado a ',
|
renamed_to: 'Renombrado a ',
|
||||||
rename_failed: 'Error al renombrar: ',
|
rename_failed: 'Error al renombrar: ',
|
||||||
@@ -7888,6 +7893,7 @@ const LOCALES = {
|
|||||||
downloading: (name) => `Lade ${name} herunter\u2026`,
|
downloading: (name) => `Lade ${name} herunter\u2026`,
|
||||||
double_click_rename: 'Doppelklick zum Umbenennen',
|
double_click_rename: 'Doppelklick zum Umbenennen',
|
||||||
symlink_link_to: 'Symlink → {target}',
|
symlink_link_to: 'Symlink → {target}',
|
||||||
|
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
|
||||||
session_rename_failed_no_row: 'Could not start rename — row not found.',
|
session_rename_failed_no_row: 'Could not start rename — row not found.',
|
||||||
renamed_to: 'Umbenannt in ',
|
renamed_to: 'Umbenannt in ',
|
||||||
rename_failed: 'Umbenennen fehlgeschlagen: ',
|
rename_failed: 'Umbenennen fehlgeschlagen: ',
|
||||||
@@ -9404,6 +9410,7 @@ const LOCALES = {
|
|||||||
downloading: (name) => `正在下载 ${name}...`,
|
downloading: (name) => `正在下载 ${name}...`,
|
||||||
double_click_rename: '双击重命名',
|
double_click_rename: '双击重命名',
|
||||||
symlink_link_to: 'Symlink → {target}',
|
symlink_link_to: 'Symlink → {target}',
|
||||||
|
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
|
||||||
session_rename_failed_no_row: 'Could not start rename — row not found.',
|
session_rename_failed_no_row: 'Could not start rename — row not found.',
|
||||||
renamed_to: '已重命名为 ',
|
renamed_to: '已重命名为 ',
|
||||||
rename_failed: '重命名失败:',
|
rename_failed: '重命名失败:',
|
||||||
@@ -10954,6 +10961,7 @@ const LOCALES = {
|
|||||||
downloading: (name) => `正在下載 ${name}…`,
|
downloading: (name) => `正在下載 ${name}…`,
|
||||||
double_click_rename: '按兩下即可重新命名',
|
double_click_rename: '按兩下即可重新命名',
|
||||||
symlink_link_to: 'Symlink → {target}',
|
symlink_link_to: 'Symlink → {target}',
|
||||||
|
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
|
||||||
session_rename_failed_no_row: '無法開始重新命名,找不到資料列。',
|
session_rename_failed_no_row: '無法開始重新命名,找不到資料列。',
|
||||||
renamed_to: '已重新命名為 ',
|
renamed_to: '已重新命名為 ',
|
||||||
rename_failed: '重新命名失敗:',
|
rename_failed: '重新命名失敗:',
|
||||||
@@ -12393,6 +12401,7 @@ const LOCALES = {
|
|||||||
downloading: (name) => `Baixando ${name}…`,
|
downloading: (name) => `Baixando ${name}…`,
|
||||||
double_click_rename: 'Duplo clique para renomear',
|
double_click_rename: 'Duplo clique para renomear',
|
||||||
symlink_link_to: 'Symlink → {target}',
|
symlink_link_to: 'Symlink → {target}',
|
||||||
|
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
|
||||||
session_rename_failed_no_row: 'Could not start rename — row not found.',
|
session_rename_failed_no_row: 'Could not start rename — row not found.',
|
||||||
renamed_to: 'Renomeado para ',
|
renamed_to: 'Renomeado para ',
|
||||||
rename_failed: 'Falha ao renomear: ',
|
rename_failed: 'Falha ao renomear: ',
|
||||||
@@ -13823,6 +13832,7 @@ const LOCALES = {
|
|||||||
downloading: (name) => `Downloading ${name}\u2026`,
|
downloading: (name) => `Downloading ${name}\u2026`,
|
||||||
double_click_rename: 'Double-click to rename',
|
double_click_rename: 'Double-click to rename',
|
||||||
symlink_link_to: 'Symlink → {target}',
|
symlink_link_to: 'Symlink → {target}',
|
||||||
|
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
|
||||||
session_rename_failed_no_row: 'Could not start rename — row not found.',
|
session_rename_failed_no_row: 'Could not start rename — row not found.',
|
||||||
renamed_to: 'Renamed to ',
|
renamed_to: 'Renamed to ',
|
||||||
rename_failed: 'Rename failed: ',
|
rename_failed: 'Rename failed: ',
|
||||||
@@ -15298,6 +15308,7 @@ const LOCALES = {
|
|||||||
file_open_failed: 'Impossible d\'ouvrir le fichier',
|
file_open_failed: 'Impossible d\'ouvrir le fichier',
|
||||||
double_click_rename: 'Double-cliquez pour renommer',
|
double_click_rename: 'Double-cliquez pour renommer',
|
||||||
symlink_link_to: 'Symlink → {target}',
|
symlink_link_to: 'Symlink → {target}',
|
||||||
|
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
|
||||||
session_rename_failed_no_row: 'Could not start rename — row not found.',
|
session_rename_failed_no_row: 'Could not start rename — row not found.',
|
||||||
renamed_to: 'Renommé en',
|
renamed_to: 'Renommé en',
|
||||||
rename_failed: 'Échec du changement de nom :',
|
rename_failed: 'Échec du changement de nom :',
|
||||||
@@ -16839,6 +16850,7 @@ const LOCALES = {
|
|||||||
downloading: (name) => `${name} indiriliyor\u2026`,
|
downloading: (name) => `${name} indiriliyor\u2026`,
|
||||||
double_click_rename: 'Yeniden adlandırmak için çift tıklayın',
|
double_click_rename: 'Yeniden adlandırmak için çift tıklayın',
|
||||||
symlink_link_to: 'Symlink → {target}',
|
symlink_link_to: 'Symlink → {target}',
|
||||||
|
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
|
||||||
session_rename_failed_no_row: 'Could not start rename — row not found.',
|
session_rename_failed_no_row: 'Could not start rename — row not found.',
|
||||||
renamed_to: 'Yeniden adlandırıldı',
|
renamed_to: 'Yeniden adlandırıldı',
|
||||||
rename_failed: 'Yeniden adlandırma başarısız oldu:',
|
rename_failed: 'Yeniden adlandırma başarısız oldu:',
|
||||||
@@ -18389,6 +18401,7 @@ const LOCALES = {
|
|||||||
downloading: (name) => `Pobieranie ${name}…`,
|
downloading: (name) => `Pobieranie ${name}…`,
|
||||||
double_click_rename: 'Kliknij dwukrotnie, aby zmienić nazwę',
|
double_click_rename: 'Kliknij dwukrotnie, aby zmienić nazwę',
|
||||||
symlink_link_to: 'Symlink → {target}',
|
symlink_link_to: 'Symlink → {target}',
|
||||||
|
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
|
||||||
session_rename_failed_no_row: 'Nie można rozpocząć zmiany nazwy — nie znaleziono wiersza.',
|
session_rename_failed_no_row: 'Nie można rozpocząć zmiany nazwy — nie znaleziono wiersza.',
|
||||||
renamed_to: 'Nazwa zmieniona na ',
|
renamed_to: 'Nazwa zmieniona na ',
|
||||||
rename_failed: 'Zmiana nazwy nie powiodła się: ',
|
rename_failed: 'Zmiana nazwy nie powiodła się: ',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const LI_PATHS = {
|
|||||||
'lightbulb': '<path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.7-3.2 6H8.2C6.3 13.7 5 11.5 5 9a7 7 0 0 1 7-7z"/><line x1="9" y1="17" x2="15" y2="17"/><line x1="10" y1="20" x2="14" y2="20"/>',
|
'lightbulb': '<path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.7-3.2 6H8.2C6.3 13.7 5 11.5 5 9a7 7 0 0 1 7-7z"/><line x1="9" y1="17" x2="15" y2="17"/><line x1="10" y1="20" x2="14" y2="20"/>',
|
||||||
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
|
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
|
||||||
'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
||||||
|
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
|
||||||
'list-todo': '<rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>',
|
'list-todo': '<rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>',
|
||||||
// Editing / actions
|
// Editing / actions
|
||||||
'pencil': '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>',
|
'pencil': '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>',
|
||||||
|
|||||||
+31
-6
@@ -5885,7 +5885,10 @@ function showConfirmDialog(opts={}){
|
|||||||
if(title) title.textContent=opts.title||t('dialog_confirm_title');
|
if(title) title.textContent=opts.title||t('dialog_confirm_title');
|
||||||
if(desc) desc.textContent=opts.message||'';
|
if(desc) desc.textContent=opts.message||'';
|
||||||
if(input){input.style.display='none';input.value='';}
|
if(input){input.style.display='none';input.value='';}
|
||||||
if(cancelBtn) cancelBtn.textContent=opts.cancelLabel||t('cancel');
|
if(cancelBtn){
|
||||||
|
if(opts.hideCancel){cancelBtn.style.display='none';}
|
||||||
|
else{cancelBtn.style.display='';cancelBtn.textContent=opts.cancelLabel||t('cancel');}
|
||||||
|
}
|
||||||
if(confirmBtn){
|
if(confirmBtn){
|
||||||
confirmBtn.textContent=opts.confirmLabel||t('dialog_confirm_btn');
|
confirmBtn.textContent=opts.confirmLabel||t('dialog_confirm_btn');
|
||||||
confirmBtn.classList.toggle('danger',!!opts.danger);
|
confirmBtn.classList.toggle('danger',!!opts.danger);
|
||||||
@@ -14350,9 +14353,13 @@ function _renderTreeItems(container, entries, depth){
|
|||||||
el.ondragend=()=>{el.classList.remove('dragging');_clearWorkspaceMoveDragOver();};
|
el.ondragend=()=>{el.classList.remove('dragging');_clearWorkspaceMoveDragOver();};
|
||||||
|
|
||||||
const isLk = item.type === 'symlink';
|
const isLk = item.type === 'symlink';
|
||||||
const isDirLike = item.type === 'dir' || (isLk && item.is_dir);
|
const isExternalLink = isLk && item.target_outside_workspace;
|
||||||
const isFileLike = !isDirLike;
|
// External symlinks are display-only: not expandable, not openable.
|
||||||
|
// The read gate (safe_resolve_ws) still blocks navigation through them.
|
||||||
|
const isDirLike = !isExternalLink && (item.type === 'dir' || (isLk && item.is_dir));
|
||||||
|
const isFileLike = !isExternalLink && !isDirLike;
|
||||||
el.dataset.wsIsDir = String(isDirLike);
|
el.dataset.wsIsDir = String(isDirLike);
|
||||||
|
if(isExternalLink){el.removeAttribute('draggable');el.ondragstart=null;}
|
||||||
|
|
||||||
if(isDirLike){
|
if(isDirLike){
|
||||||
// Toggle arrow for directories
|
// Toggle arrow for directories
|
||||||
@@ -14373,9 +14380,11 @@ function _renderTreeItems(container, entries, depth){
|
|||||||
// Icon
|
// Icon
|
||||||
const iconEl=document.createElement('span');
|
const iconEl=document.createElement('span');
|
||||||
iconEl.className='file-icon';
|
iconEl.className='file-icon';
|
||||||
iconEl.innerHTML = isDirLike
|
iconEl.innerHTML = isExternalLink
|
||||||
? (isLk ? li('link', 14) : li('folder', 14))
|
? li('external-link', 14)
|
||||||
: (isLk ? li('link', 14) : fileIcon(item.name, item.type));
|
: isDirLike
|
||||||
|
? (isLk ? li('link', 14) : li('folder', 14))
|
||||||
|
: (isLk ? li('link', 14) : fileIcon(item.name, item.type));
|
||||||
el.appendChild(iconEl);
|
el.appendChild(iconEl);
|
||||||
|
|
||||||
// Name
|
// Name
|
||||||
@@ -14407,6 +14416,8 @@ function _renderTreeItems(container, entries, depth){
|
|||||||
if(_nameClickTimer){clearTimeout(_nameClickTimer);_nameClickTimer=null;}
|
if(_nameClickTimer){clearTimeout(_nameClickTimer);_nameClickTimer=null;}
|
||||||
// For directories, double-click navigates (breadcrumb view)
|
// For directories, double-click navigates (breadcrumb view)
|
||||||
if(isDirLike){loadDir(item.path);return;}
|
if(isDirLike){loadDir(item.path);return;}
|
||||||
|
// External symlinks: show informational dialog, not rename
|
||||||
|
if(isExternalLink){if(typeof el.onclick==='function')el.onclick(e);return;}
|
||||||
const inp=document.createElement('input');
|
const inp=document.createElement('input');
|
||||||
inp.className='file-rename-input';inp.value=item.name;
|
inp.className='file-rename-input';inp.value=item.name;
|
||||||
inp.onclick=(e2)=>e2.stopPropagation();
|
inp.onclick=(e2)=>e2.stopPropagation();
|
||||||
@@ -14495,6 +14506,20 @@ function _renderTreeItems(container, entries, depth){
|
|||||||
renderFileTree();
|
renderFileTree();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
}else if(isExternalLink){
|
||||||
|
// Display-only: show the resolved target, do NOT call openFile.
|
||||||
|
// The read gate (safe_resolve_ws) blocks navigation through the link.
|
||||||
|
el.onclick=async(e)=>{
|
||||||
|
e.stopPropagation();
|
||||||
|
await showConfirmDialog({
|
||||||
|
title:item.name,
|
||||||
|
message:t('external_link_open_confirm').replace('{target}',()=>elideMiddle(item.target||'')),
|
||||||
|
confirmLabel:t('dialog_confirm_btn'),
|
||||||
|
danger:false,
|
||||||
|
hideCancel:true,
|
||||||
|
focusCancel:false,
|
||||||
|
});
|
||||||
|
};
|
||||||
}else{
|
}else{
|
||||||
el.onclick=async()=>openFile(item.path);
|
el.onclick=async()=>openFile(item.path);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -248,15 +248,16 @@ const trackedClearTimeout = (id) => {
|
|||||||
// Symlink locals referenced by the handler block — declared from item
|
// Symlink locals referenced by the handler block — declared from item
|
||||||
// just like _renderTreeItem does before the tooltip assignment.
|
// just like _renderTreeItem does before the tooltip assignment.
|
||||||
const isLk = item.type === 'symlink';
|
const isLk = item.type === 'symlink';
|
||||||
const isDirLike = item.type === 'dir' || (isLk && item.is_dir);
|
const isExternalLink = isLk && item.target_outside_workspace;
|
||||||
|
const isDirLike = !isExternalLink && (item.type === 'dir' || (isLk && item.is_dir));
|
||||||
const elideMiddle = (s) => s;
|
const elideMiddle = (s) => s;
|
||||||
|
|
||||||
const runner = new Function(
|
const runner = new Function(
|
||||||
'nameEl', 'el', 'item', 'S', 't', 'loadDir', 'document', 'showToast', 'api', 'window',
|
'nameEl', 'el', 'item', 'S', 't', 'loadDir', 'document', 'showToast', 'api', 'window',
|
||||||
'setTimeout', 'clearTimeout', 'isLk', 'isDirLike', 'elideMiddle',
|
'setTimeout', 'clearTimeout', 'isLk', 'isExternalLink', 'isDirLike', 'elideMiddle',
|
||||||
'(()=>{' + handlerBlock + '})();'
|
'(()=>{' + handlerBlock + '})();'
|
||||||
);
|
);
|
||||||
runner(nameEl, el, item, S, t, loadDir, document, showToast, api, {}, trackedSetTimeout, trackedClearTimeout, isLk, isDirLike, elideMiddle);
|
runner(nameEl, el, item, S, t, loadDir, document, showToast, api, {}, trackedSetTimeout, trackedClearTimeout, isLk, isExternalLink, isDirLike, elideMiddle);
|
||||||
|
|
||||||
const evt = { stopPropagation: () => {} };
|
const evt = { stopPropagation: () => {} };
|
||||||
for (let i = 0; i < clickCount; i++) {
|
for (let i = 0; i < clickCount; i++) {
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ recursion. Covers:
|
|||||||
- Self-referencing symlink (ln -s . ~/workspace/loop)
|
- Self-referencing symlink (ln -s . ~/workspace/loop)
|
||||||
- Ancestor symlink (ln -s .. ~/workspace/up)
|
- Ancestor symlink (ln -s .. ~/workspace/up)
|
||||||
- Internal symlink entries carry correct type / is_dir / target fields
|
- Internal symlink entries carry correct type / is_dir / target fields
|
||||||
- External symlink directories are hidden from listings and cannot be traversed
|
- External symlink directories are emitted as display-only rows (target_outside_workspace=True)
|
||||||
|
and cannot be traversed
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -57,8 +58,8 @@ def make_session(created_list, ws=None):
|
|||||||
class TestSymlinkCycleDetection:
|
class TestSymlinkCycleDetection:
|
||||||
"""Symlink cycle detection in list_dir / safe_resolve_ws."""
|
"""Symlink cycle detection in list_dir / safe_resolve_ws."""
|
||||||
|
|
||||||
def test_external_symlink_filtered_from_listing(self, cleanup_test_sessions, tmp_path_factory):
|
def test_external_symlink_emitted_as_display_only(self, cleanup_test_sessions, tmp_path_factory):
|
||||||
"""External symlink dirs should be hidden from workspace listings."""
|
"""External symlink dirs are emitted with target_outside_workspace=True (display-only)."""
|
||||||
ws = tmp_path_factory.mktemp("ws")
|
ws = tmp_path_factory.mktemp("ws")
|
||||||
target = tmp_path_factory.mktemp("target")
|
target = tmp_path_factory.mktemp("target")
|
||||||
(target / "file.txt").write_text("hello")
|
(target / "file.txt").write_text("hello")
|
||||||
@@ -67,8 +68,11 @@ class TestSymlinkCycleDetection:
|
|||||||
|
|
||||||
sid, _ = make_session(cleanup_test_sessions, ws)
|
sid, _ = make_session(cleanup_test_sessions, ws)
|
||||||
listing = get(f"/api/list?session_id={sid}&path=.")
|
listing = get(f"/api/list?session_id={sid}&path=.")
|
||||||
names = [e["name"] for e in listing["entries"]]
|
entries = {e["name"]: e for e in listing["entries"]}
|
||||||
assert "ext" not in names
|
assert "ext" in entries
|
||||||
|
assert entries["ext"]["type"] == "symlink"
|
||||||
|
assert entries["ext"]["target_outside_workspace"] is True
|
||||||
|
assert entries["ext"]["is_dir"] is True
|
||||||
|
|
||||||
def test_internal_symlink_listed_as_symlink(self, cleanup_test_sessions, tmp_path_factory):
|
def test_internal_symlink_listed_as_symlink(self, cleanup_test_sessions, tmp_path_factory):
|
||||||
"""Internal symlink dirs should appear with type='symlink', is_dir=True."""
|
"""Internal symlink dirs should appear with type='symlink', is_dir=True."""
|
||||||
@@ -149,10 +153,11 @@ class TestSymlinkCycleDetection:
|
|||||||
(ws / "ext").symlink_to(target)
|
(ws / "ext").symlink_to(target)
|
||||||
|
|
||||||
sid, _ = make_session(cleanup_test_sessions, ws)
|
sid, _ = make_session(cleanup_test_sessions, ws)
|
||||||
# List root — should hide the external symlink and not recurse.
|
# List root — external symlink is emitted as display-only, not traversed.
|
||||||
listing = get(f"/api/list?session_id={sid}&path=.")
|
listing = get(f"/api/list?session_id={sid}&path=.")
|
||||||
names = [e["name"] for e in listing["entries"]]
|
entries = {e["name"]: e for e in listing["entries"]}
|
||||||
assert "ext" not in names
|
assert "ext" in entries
|
||||||
|
assert entries["ext"]["target_outside_workspace"] is True
|
||||||
|
|
||||||
# Traversing into ext/subdir crosses the workspace boundary and is blocked.
|
# Traversing into ext/subdir crosses the workspace boundary and is blocked.
|
||||||
try:
|
try:
|
||||||
@@ -177,8 +182,8 @@ class TestSymlinkCycleDetection:
|
|||||||
assert link[0]["is_dir"] is False
|
assert link[0]["is_dir"] is False
|
||||||
assert link[0]["size"] == 11 # len("hello world")
|
assert link[0]["size"] == 11 # len("hello world")
|
||||||
|
|
||||||
def test_external_symlink_file_filtered_from_listing(self, cleanup_test_sessions, tmp_path_factory):
|
def test_external_symlink_file_emitted_as_display_only(self, cleanup_test_sessions, tmp_path_factory):
|
||||||
"""External symlink files should be hidden from workspace listings."""
|
"""External symlink files are emitted with target_outside_workspace=True (display-only)."""
|
||||||
ws = tmp_path_factory.mktemp("ws")
|
ws = tmp_path_factory.mktemp("ws")
|
||||||
real = tmp_path_factory.mktemp("real")
|
real = tmp_path_factory.mktemp("real")
|
||||||
(real / "data.txt").write_text("hello world")
|
(real / "data.txt").write_text("hello world")
|
||||||
@@ -186,8 +191,11 @@ class TestSymlinkCycleDetection:
|
|||||||
|
|
||||||
sid, _ = make_session(cleanup_test_sessions, ws)
|
sid, _ = make_session(cleanup_test_sessions, ws)
|
||||||
listing = get(f"/api/list?session_id={sid}&path=.")
|
listing = get(f"/api/list?session_id={sid}&path=.")
|
||||||
names = [e["name"] for e in listing["entries"]]
|
entries = {e["name"]: e for e in listing["entries"]}
|
||||||
assert "link.txt" not in names
|
assert "link.txt" in entries
|
||||||
|
assert entries["link.txt"]["type"] == "symlink"
|
||||||
|
assert entries["link.txt"]["target_outside_workspace"] is True
|
||||||
|
assert entries["link.txt"]["is_dir"] is False
|
||||||
|
|
||||||
def test_path_traversal_still_blocked(self, cleanup_test_sessions, tmp_path_factory):
|
def test_path_traversal_still_blocked(self, cleanup_test_sessions, tmp_path_factory):
|
||||||
"""Raw .. traversal must still be blocked even with symlink support."""
|
"""Raw .. traversal must still be blocked even with symlink support."""
|
||||||
|
|||||||
@@ -11,13 +11,19 @@ def test_safe_resolve_blocks_external_symlink_directory(tmp_path):
|
|||||||
(outside / "secret.txt").write_text("outside", encoding="utf-8")
|
(outside / "secret.txt").write_text("outside", encoding="utf-8")
|
||||||
(workspace / "escape").symlink_to(outside)
|
(workspace / "escape").symlink_to(outside)
|
||||||
|
|
||||||
|
# The read/list gate still blocks navigation through the escape symlink.
|
||||||
with pytest.raises(ValueError, match="Path traversal blocked"):
|
with pytest.raises(ValueError, match="Path traversal blocked"):
|
||||||
safe_resolve_ws(workspace, "escape")
|
safe_resolve_ws(workspace, "escape")
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Path traversal blocked"):
|
with pytest.raises(ValueError, match="Path traversal blocked"):
|
||||||
list_dir(workspace, "escape")
|
list_dir(workspace, "escape")
|
||||||
|
|
||||||
assert "escape" not in {entry["name"] for entry in list_dir(workspace, ".")}
|
# The escape symlink is now emitted (display-only) with target_outside_workspace=True.
|
||||||
|
entries = {e["name"]: e for e in list_dir(workspace, ".")}
|
||||||
|
assert "escape" in entries
|
||||||
|
assert entries["escape"]["type"] == "symlink"
|
||||||
|
assert entries["escape"]["target_outside_workspace"] is True
|
||||||
|
assert entries["escape"]["is_dir"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_read_file_blocks_external_symlink_file(tmp_path):
|
def test_read_file_blocks_external_symlink_file(tmp_path):
|
||||||
@@ -28,10 +34,16 @@ def test_read_file_blocks_external_symlink_file(tmp_path):
|
|||||||
(outside / "secret.txt").write_text("outside", encoding="utf-8")
|
(outside / "secret.txt").write_text("outside", encoding="utf-8")
|
||||||
(workspace / "secret-link.txt").symlink_to(outside / "secret.txt")
|
(workspace / "secret-link.txt").symlink_to(outside / "secret.txt")
|
||||||
|
|
||||||
|
# The read gate still blocks reading through the escape symlink.
|
||||||
with pytest.raises(ValueError, match="Path traversal blocked"):
|
with pytest.raises(ValueError, match="Path traversal blocked"):
|
||||||
read_file_content(workspace, "secret-link.txt")
|
read_file_content(workspace, "secret-link.txt")
|
||||||
|
|
||||||
assert "secret-link.txt" not in {entry["name"] for entry in list_dir(workspace, ".")}
|
# The escape symlink is now emitted (display-only) with target_outside_workspace=True.
|
||||||
|
entries = {e["name"]: e for e in list_dir(workspace, ".")}
|
||||||
|
assert "secret-link.txt" in entries
|
||||||
|
assert entries["secret-link.txt"]["type"] == "symlink"
|
||||||
|
assert entries["secret-link.txt"]["target_outside_workspace"] is True
|
||||||
|
assert entries["secret-link.txt"]["is_dir"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_internal_symlink_still_resolves_within_workspace(tmp_path):
|
def test_internal_symlink_still_resolves_within_workspace(tmp_path):
|
||||||
@@ -230,7 +242,9 @@ def test_list_read_create_work_on_no_dir_fd_fallback(tmp_path, monkeypatch):
|
|||||||
assert "a.txt" in names
|
assert "a.txt" in names
|
||||||
if w._DIR_FD_OK:
|
if w._DIR_FD_OK:
|
||||||
assert "internal" in names # legit internal symlink listed
|
assert "internal" in names # legit internal symlink listed
|
||||||
assert "escape" not in names # external symlink hidden
|
assert "escape" in names # external symlink emitted (display-only)
|
||||||
|
escape_entry = next(e for e in w.list_dir(workspace, ".") if e["name"] == "escape")
|
||||||
|
assert escape_entry["target_outside_workspace"] is True
|
||||||
assert w.read_file_content(workspace, "a.txt")["content"] == "hi"
|
assert w.read_file_content(workspace, "a.txt")["content"] == "hi"
|
||||||
|
|
||||||
fd = w.open_anchored_create_fd(workspace, workspace / "new" / "f.txt")
|
fd = w.open_anchored_create_fd(workspace, workspace / "new" / "f.txt")
|
||||||
@@ -277,3 +291,80 @@ def test_read_blocked_when_workspace_root_raced_to_symlink(tmp_path):
|
|||||||
pass # refused — correct
|
pass # refused — correct
|
||||||
finally:
|
finally:
|
||||||
os.open = real_open
|
os.open = real_open
|
||||||
|
|
||||||
|
|
||||||
|
# ── #4510: escape-target symlinks are now emitted as display-only rows ──────
|
||||||
|
# The escape filter was widened (not removed): symlinks whose resolved target
|
||||||
|
# sits outside the workspace root are now emitted with
|
||||||
|
# target_outside_workspace=True instead of being silently dropped. The
|
||||||
|
# read/list gate (safe_resolve_ws / open_anchored_fd) is unchanged and still
|
||||||
|
# blocks navigation through them. ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_dir_in_workspace_symlink_shape(tmp_path):
|
||||||
|
"""In-workspace symlinks emit type='symlink' with target_outside_workspace=False."""
|
||||||
|
import api.workspace as w
|
||||||
|
|
||||||
|
if not w._DIR_FD_OK:
|
||||||
|
pytest.skip("symlink listing requires dir_fd support")
|
||||||
|
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
(workspace / "data.txt").write_text("hello", encoding="utf-8")
|
||||||
|
(workspace / "link.txt").symlink_to(workspace / "data.txt")
|
||||||
|
|
||||||
|
entries = {e["name"]: e for e in w.list_dir(workspace, ".")}
|
||||||
|
assert "link.txt" in entries
|
||||||
|
assert entries["link.txt"]["type"] == "symlink"
|
||||||
|
assert entries["link.txt"]["target_outside_workspace"] is False
|
||||||
|
assert entries["link.txt"]["is_dir"] is False
|
||||||
|
assert entries["link.txt"]["target"] == str((workspace / "data.txt").resolve())
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_dir_outside_workspace_symlink_emitted_with_flag(tmp_path):
|
||||||
|
"""Escape-target symlinks are emitted with target_outside_workspace=True."""
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
outside = tmp_path / "outside"
|
||||||
|
workspace.mkdir()
|
||||||
|
outside.mkdir()
|
||||||
|
(outside / "file.txt").write_text("external", encoding="utf-8")
|
||||||
|
(workspace / "ext-link.txt").symlink_to(outside / "file.txt")
|
||||||
|
|
||||||
|
entries = {e["name"]: e for e in list_dir(workspace, ".")}
|
||||||
|
assert "ext-link.txt" in entries
|
||||||
|
assert entries["ext-link.txt"]["type"] == "symlink"
|
||||||
|
assert entries["ext-link.txt"]["target_outside_workspace"] is True
|
||||||
|
assert entries["ext-link.txt"]["is_dir"] is False
|
||||||
|
assert entries["ext-link.txt"]["target"] == str((outside / "file.txt").resolve())
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_dir_external_symlink_blocked_system_path_unchanged(tmp_path):
|
||||||
|
"""Symlinks to blocked system paths (/etc, /usr) are still filtered out."""
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
(workspace / "etc-link").symlink_to("/etc")
|
||||||
|
(workspace / "usr-link").symlink_to("/usr")
|
||||||
|
|
||||||
|
names = {e["name"] for e in list_dir(workspace, ".")}
|
||||||
|
assert "etc-link" not in names
|
||||||
|
assert "usr-link" not in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_dir_escape_symlink_read_still_blocked(tmp_path):
|
||||||
|
"""Listing shows the escape symlink (display-only) but read_file_content
|
||||||
|
on the same target still raises ValueError — proving the read gate is intact."""
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
outside = tmp_path / "outside"
|
||||||
|
workspace.mkdir()
|
||||||
|
outside.mkdir()
|
||||||
|
(outside / "secret.txt").write_text("secret", encoding="utf-8")
|
||||||
|
(workspace / "escape.txt").symlink_to(outside / "secret.txt")
|
||||||
|
|
||||||
|
# Listing emits the entry with target_outside_workspace=True
|
||||||
|
entries = {e["name"]: e for e in list_dir(workspace, ".")}
|
||||||
|
assert "escape.txt" in entries
|
||||||
|
assert entries["escape.txt"]["target_outside_workspace"] is True
|
||||||
|
|
||||||
|
# But reading through it is still blocked
|
||||||
|
with pytest.raises(ValueError, match="Path traversal blocked"):
|
||||||
|
read_file_content(workspace, "escape.txt")
|
||||||
@@ -27,14 +27,19 @@ class TestIsDirLikeLocals:
|
|||||||
assert "const isLk = item.type === 'symlink';" in block, \
|
assert "const isLk = item.type === 'symlink';" in block, \
|
||||||
"isLk local must be declared inside the per-item loop"
|
"isLk local must be declared inside the per-item loop"
|
||||||
|
|
||||||
|
def test_isExternalLink_local_declared(self):
|
||||||
|
block = _render_block()
|
||||||
|
assert "const isExternalLink = isLk && item.target_outside_workspace;" in block, \
|
||||||
|
"isExternalLink local must be declared for display-only external symlinks"
|
||||||
|
|
||||||
def test_isDirLike_local_declared(self):
|
def test_isDirLike_local_declared(self):
|
||||||
block = _render_block()
|
block = _render_block()
|
||||||
assert "const isDirLike = item.type === 'dir' || (isLk && item.is_dir);" in block, \
|
assert "const isDirLike = !isExternalLink && (item.type === 'dir' || (isLk && item.is_dir));" in block, \
|
||||||
"isDirLike local must be declared inside the per-item loop"
|
"isDirLike local must be declared inside the per-item loop"
|
||||||
|
|
||||||
def test_isFileLike_local_declared(self):
|
def test_isFileLike_local_declared(self):
|
||||||
block = _render_block()
|
block = _render_block()
|
||||||
assert "const isFileLike = !isDirLike;" in block, \
|
assert "const isFileLike = !isExternalLink && !isDirLike;" in block, \
|
||||||
"isFileLike local must be declared for file-like symlink handling"
|
"isFileLike local must be declared for file-like symlink handling"
|
||||||
|
|
||||||
def test_wsIsDir_dataset_set(self):
|
def test_wsIsDir_dataset_set(self):
|
||||||
@@ -65,6 +70,15 @@ class TestLinkIcon:
|
|||||||
assert "li('link', 14)" in block, \
|
assert "li('link', 14)" in block, \
|
||||||
"icon dispatch must emit li('link', 14) for symlink rows"
|
"icon dispatch must emit li('link', 14) for symlink rows"
|
||||||
|
|
||||||
|
def test_external_link_icon_path_exists(self):
|
||||||
|
assert "'external-link':" in ICONS_JS, \
|
||||||
|
"'external-link' key must be in LI_PATHS for display-only external symlinks"
|
||||||
|
|
||||||
|
def test_icon_dispatch_uses_li_external_link(self):
|
||||||
|
block = _render_block()
|
||||||
|
assert "li('external-link', 14)" in block, \
|
||||||
|
"icon dispatch must emit li('external-link', 14) for external symlink rows"
|
||||||
|
|
||||||
|
|
||||||
class TestSymlinkTooltip:
|
class TestSymlinkTooltip:
|
||||||
def test_symlink_link_to_key_in_i18n(self):
|
def test_symlink_link_to_key_in_i18n(self):
|
||||||
|
|||||||
Reference in new issue
Block a user