Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/e2e-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,7 @@ jobs:
BINARY="apps/desktop/src-tauri/target/debug/${{ matrix.binary-name }}"
DEV_KEY=$(openssl rand -hex 32)
LOGFILE="$RUNNER_TEMP/cipherbox-desktop.log"
export DESKTOP_LOG="$LOGFILE"
"$BINARY" --dev-key "$DEV_KEY" > "$LOGFILE" 2>&1 &
DESKTOP_PID=$!
set +e
Expand Down
14 changes: 11 additions & 3 deletions apps/desktop/src-tauri/src/fuse/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -909,9 +909,17 @@ impl CipherBoxFS {
);
}
}
// Move plaintext from pending_content to content_cache
if let Some(plaintext) = self.pending_content.remove(&result.ino) {
self.content_cache.set(&result.new_cid, plaintext);
// Move plaintext from pending_content to content_cache — but only
// if the generation matched. For stale uploads, pending_content
// already holds the newer cycle's plaintext; consuming it here
// would pollute content_cache with a stale CID key and leave
// the correct CID with no cache entry.
if let Some(inode) = self.inodes.get(result.ino) {
if inode.write_generation == result.write_generation {
if let Some(plaintext) = self.pending_content.remove(&result.ino) {
self.content_cache.set(&result.new_cid, plaintext);
}
}
}
// Old file CID is now preserved as a version entry -- do NOT unpin it.
// Only unpin CIDs from pruned versions that exceeded MAX_VERSIONS_PER_FILE.
Expand Down
17 changes: 16 additions & 1 deletion apps/desktop/src-tauri/src/fuse/windows/write_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,11 @@ pub(crate) mod implementation {
inode.attr.size = 0;
inode.attr.mtime = SystemTime::now();
inode.attr.ctime = SystemTime::now();
inode.write_generation += 1;
if let InodeKind::File { size: ref mut s, cid: ref mut c, .. } = inode.kind {
*s = 0;
c.clear();
}
*file_info = fill_file_info(&inode.attr);
}

Expand Down Expand Up @@ -463,6 +468,9 @@ pub(crate) mod implementation {
inode.attr.size = new_size;
inode.attr.blocks = (new_size + 511) / 512;
inode.attr.mtime = SystemTime::now();
if new_size == 0 {
inode.write_generation += 1;
}
if let InodeKind::File { size: ref mut s, cid: ref mut c, .. } = inode.kind {
*s = new_size;
if new_size == 0 {
Expand Down Expand Up @@ -791,13 +799,20 @@ pub(crate) mod implementation {
file_ipns_key_encrypted_hex: cached_hex,
versions: versions_for_meta.clone(),
};
// Bump generation so stale background uploads (from a
// prior truncate-to-zero flush) are rejected by
// drain_upload_completions.
inode.write_generation += 1;
inode.attr.size = file_size;
inode.attr.blocks = (file_size + 511) / 512;
inode.attr.mtime = SystemTime::now();
}

fs.pending_content.insert(ino, plaintext);

let write_gen = fs.inodes.get(ino)
.map(|i| i.write_generation)
.unwrap_or(0);
Comment thread
FSM1 marked this conversation as resolved.
let parent_ino = fs.inodes.get(ino).map(|i| i.parent_ino).unwrap_or(ROOT_INO);
let folder_key_for_file_meta = fs.get_folder_key(parent_ino);
fs.queue_publish(parent_ino, true);
Expand Down Expand Up @@ -831,7 +846,7 @@ pub(crate) mod implementation {
parent_ino,
old_file_cid,
pruned_cids,
write_generation: 0,
write_generation: write_gen,
});

if let (Some(ipns_key), Some(ipns_name), Some(folder_key)) =
Expand Down
39 changes: 15 additions & 24 deletions tests/e2e-desktop/scripts/test-recycle-bin.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#
# Environment:
# TEST_SECRET test-login shared secret (default: e2e-test-secret-ci-only)
# DESKTOP_LOG Path to desktop binary log file (enables log-based bin publish verification)
#
# Tests:
# 1. Create test file on FUSE mount
Expand Down Expand Up @@ -146,33 +147,23 @@ if ($FileGone -and $HadContent) {
Test-Fail "Soft-delete behavior (file still on mount after deletion)"
}

# ---- Test 5: Verify bin entry exists in bin metadata (GAP-2 fix) ----
Write-Host "--- Test 5: Verify bin entry created in bin metadata ---"
# Give the bin IPNS publish time to propagate
# ---- Test 5: Verify bin entry published (via desktop log) ----
Write-Host "--- Test 5: Verify bin entry published ---"
# The bin IPNS name is derived client-side from the user's private key,
# so we verify by checking the desktop log for the publish confirmation.
Start-Sleep -Seconds 5
try {
$VaultConfig = Invoke-RestMethod -Uri "$ApiUrl/vault/config" -Headers $Headers
$BinIpnsName = $VaultConfig.recycleBinIpnsName

if (-not $BinIpnsName) {
Test-Fail "Bin entry verification (no recycleBinIpnsName in vault config)"
$DesktopLog = $env:DESKTOP_LOG
if ($DesktopLog -and (Test-Path $DesktopLog)) {
Comment thread
FSM1 marked this conversation as resolved.
$LogContent = Get-Content $DesktopLog -Raw
if ($LogContent -match "Bin entry published for") {
Test-Pass "Bin entry published (confirmed via desktop log)"
} else {
# Verify the bin IPNS resolves (meaning bin metadata was published)
try {
$BinResolved = Invoke-RestMethod -Uri "$ApiUrl/ipns/resolve?ipnsName=$BinIpnsName" -Headers $Headers
if ($BinResolved -and $BinResolved.cid) {
Test-Pass "Bin entry verification (bin IPNS resolves to CID: $($BinResolved.cid))"
} else {
Test-Fail "Bin entry verification (bin IPNS did not resolve)"
}
} catch {
# IPNS resolve may fail if delegated routing is slow; fall back to
# just verifying the bin IPNS name is non-empty (lighter assertion)
Test-Pass "Bin entry verification (recycleBinIpnsName present: $BinIpnsName, resolve skipped)"
}
Test-Fail "Bin entry verification (no 'Bin entry published' in desktop log)"
}
} catch {
Test-Fail "Bin entry verification ($_)"
} else {
# No log file available -- fall back to verifying soft-delete passed
# (Tests 2+4 already confirm the file was removed via FUSE delete path)
Test-Pass "Bin entry verification (log not available, soft-delete already verified)"
}

# ---- Cleanup ----
Expand Down
8 changes: 8 additions & 0 deletions tests/e2e/page-objects/file-browser/upload-zone.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ export class UploadZonePage {
* @param filePath - Absolute or relative path to the file
*/
async uploadFile(filePath: string): Promise<void> {
// Clear previous selection so re-uploading the same file path triggers change
await this.fileInput().evaluate((el: HTMLInputElement) => {
el.value = '';
});
await this.fileInput().setInputFiles(filePath);
}

Expand All @@ -51,6 +55,10 @@ export class UploadZonePage {
* @param filePaths - Array of file paths
*/
async uploadFiles(filePaths: string[]): Promise<void> {
// Clear previous selection so re-uploading the same file paths triggers change
await this.fileInput().evaluate((el: HTMLInputElement) => {
el.value = '';
});
await this.fileInput().setInputFiles(filePaths);
}

Expand Down
50 changes: 19 additions & 31 deletions tests/e2e/tests/recycle-bin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ test.describe.serial('Recycle Bin', () => {
// Unique suffix per test run to avoid naming collisions
const runId = Date.now().toString();

/** Best-effort Zustand quota store accessor (returns null if store not exposed). */
type QuotaStore = { getState: () => { usedBytes: number } };
const getQuotaUsedBytes = (p: Page) =>
p.evaluate(() => {
try {
const store = (window as unknown as Record<string, Record<string, QuotaStore> | undefined>)
.__ZUSTAND_STORES__?.quota;
if (!store) return null;
return store.getState().usedBytes as number;
} catch {
return null;
}
});

test.beforeAll(async ({ browser: testBrowser }) => {
browser = testBrowser;
context = await browser.newContext();
Expand Down Expand Up @@ -350,20 +364,10 @@ test.describe.serial('Recycle Bin', () => {
// SECONDARY ASSERTION (best-effort): check quota via Zustand store if accessible
// This is fragile because window.__ZUSTAND_STORES__ may not be exposed.
// The primary value of this test is proving permanent delete doesn't crash.
const quotaDecreased = await page.evaluate(() => {
try {
const store = (window as any).__ZUSTAND_STORES__?.quota;
if (!store) return null; // Store not accessible, skip
// Just verify store is functional (not crashed)
const state = store.getState();
return typeof state.usedBytes === 'number';
} catch {
return null;
}
});
const quotaUsed = await getQuotaUsedBytes(page);
// Only assert if store was accessible -- null means we couldn't check
if (quotaDecreased !== null) {
expect(quotaDecreased).toBe(true);
if (quotaUsed !== null) {
expect(typeof quotaUsed === 'number').toBe(true);

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assertion at line 370 is a tautological no-op. The getQuotaUsedBytes helper returns number | null from its type signature, and by the time line 370 executes, quotaUsed has already been confirmed to be non-null by the surrounding if (quotaUsed !== null) guard. Therefore typeof quotaUsed === 'number' is always true, and expect(typeof quotaUsed === 'number').toBe(true) never fails under any circumstances. The intent of this secondary assertion (verifying the store is functional and actually holds a numeric value) is preserved by the null-check alone. Consider either removing this inner assertion entirely, or replacing it with a more meaningful check such as expect(quotaUsed).toBeGreaterThanOrEqual(0).

Suggested change
expect(typeof quotaUsed === 'number').toBe(true);
expect(quotaUsed).toBeGreaterThanOrEqual(0);

Copilot uses AI. Check for mistakes.
}
});

Expand Down Expand Up @@ -401,15 +405,7 @@ test.describe.serial('Recycle Bin', () => {
await page.waitForTimeout(3000); // Wait for IPNS publish

// 5. Snapshot quota usage (best-effort)
const quotaBefore = await page.evaluate(() => {
try {
const store = (window as any).__ZUSTAND_STORES__?.quota;
if (!store) return null;
return store.getState().usedBytes as number;
} catch {
return null;
}
});
const quotaBefore = await getQuotaUsedBytes(page);

// 6. Soft-delete the file
await fileList.rightClickItem(fileName);
Expand All @@ -434,15 +430,7 @@ test.describe.serial('Recycle Bin', () => {
if (quotaBefore !== null) {
// Wait for quota to settle after unpinning
await page.waitForTimeout(3000);
const quotaAfter = await page.evaluate(() => {
try {
const store = (window as any).__ZUSTAND_STORES__?.quota;
if (!store) return null;
return store.getState().usedBytes as number;
} catch {
return null;
}
});
const quotaAfter = await getQuotaUsedBytes(page);
if (quotaAfter !== null) {
const reclaimed = quotaBefore - quotaAfter;
// Must reclaim more than just v2 content size — proves version CIDs were also freed
Expand Down