diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 090571e..0f79b09 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,7 @@ jobs: build-release: name: Build and Release runs-on: ubuntu-24.04 + environment: 'rpm signing' steps: - name: Checkout repository @@ -30,7 +31,8 @@ jobs: libsecret-1-dev \ libdbus-1-dev \ desktop-file-utils \ - rpm + rpm \ + rpm-sign - name: Set up Rust uses: actions-rust-lang/setup-rust-toolchain@v1 @@ -154,6 +156,26 @@ jobs: # Copy built RPM to root directory cp rpmbuild/RPMS/x86_64/*.rpm . + - name: Sign Fedora package (.rpm) + env: + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + GPG_KEY_NAME: ${{ secrets.GPG_KEY_NAME }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + run: | + if [ -n "$GPG_PRIVATE_KEY" ] && [ -n "$GPG_KEY_NAME" ]; then + echo "$GPG_PRIVATE_KEY" | gpg --import + + cat < ~/.rpmmacros + %_signature gpg + %_gpg_name ${GPG_KEY_NAME} + %__gpg_sign_cmd %{__gpg} gpg --batch --verbose --no-armor --pinentry-mode loopback --passphrase %{_gpg_passphrase} --no-secmem-warning -u "%{_gpg_name}" -sbo %{__signature_filename} --digest-algo sha256 %{__plaintext_filename} + EOF + + rpm --addsign --define "_gpg_passphrase $GPG_PASSPHRASE" *.rpm + else + echo "Skipping RPM signing: GPG_PRIVATE_KEY or GPG_KEY_NAME secret is not set." + fi + - name: Upload Workflow Artifacts uses: actions/upload-artifact@v4 diff --git a/crates/pdfs-fuse/src/filesystem.rs b/crates/pdfs-fuse/src/filesystem.rs index da8fbe3..53828f5 100644 --- a/crates/pdfs-fuse/src/filesystem.rs +++ b/crates/pdfs-fuse/src/filesystem.rs @@ -992,7 +992,15 @@ impl ProtonFs { warn!(uid = %root.uid, error = %e, "db upsert root failed"); } st.by_uid.insert(root.uid.clone(), ROOT_INO); - let access = st.access_for_node(ROOT_INO, &root); + // The filesystem root is always owned — it must never be + // classified as a shared root, even when the API returns + // membership data (which happens when the owner shares the + // folder with someone else). Clean up any stale share_access + // row that a previous downgrade_all_share_access may have + // written for this uid, so hydrate_access cannot pick it up. + let access = Access::Owner; + st.share_access.remove(&root.uid); + let _ = st.db.delete_share_access(&root.uid); st.entries.insert( ROOT_INO, Entry { diff --git a/crates/pdfs-fuse/src/state.rs b/crates/pdfs-fuse/src/state.rs index eef6861..7613824 100644 --- a/crates/pdfs-fuse/src/state.rs +++ b/crates/pdfs-fuse/src/state.rs @@ -448,8 +448,13 @@ impl State { let roots: Vec = self .entries .iter() - .filter(|entry| { - let (_, entry) = entry; + .filter(|&(&ino, entry)| { + // The filesystem root (parent == self) is always owned; never + // downgrade it even if it carries membership from the owner + // sharing this folder with someone else. + if entry.parent == ino { + return false; + } entry.access != Access::Owner || entry.node.membership.is_some() || self.share_access.contains_key(&entry.uid) @@ -494,10 +499,12 @@ impl State { return Access::Unknown; } let stored = state.share_access.get(&entry.uid).copied(); - let access = if let Some(stored) = stored { - stored - } else if entry.parent == ino { + // The root of this mount is always owned, regardless of any + // stale share_access row that a global downgrade may have left. + let access = if entry.parent == ino { Access::Owner + } else if let Some(stored) = stored { + stored } else if !state.entries.contains_key(&entry.parent) { entry .node @@ -538,8 +545,12 @@ impl State { } let discovered: Vec<(NodeUid, Access)> = self .entries - .values() - .filter_map(|entry| { + .iter() + .filter_map(|(&ino, entry)| { + // The filesystem root is owned, not a discovered share root. + if entry.parent == ino { + return None; + } let parent_access = self .entries .get(&entry.parent) diff --git a/docs/BUGS.md b/docs/BUGS.md index b00fa71..7416150 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -12,6 +12,21 @@ Conventions: --- +## B78 — Profile backup fails: Cannot create file at the root of a device + +**Status:** Open +**Found:** 2026-07-29, user reported `upload profile: proton api error NotEnoughPermissions (http 422): Cannot create file at the root of a device` + +The background task that backs up the machine's profile (sync folder mappings, pins, cache budget) currently attempts to write `profile.json` directly to the device root node (`device.root_uid`) on the Drive backend. The Proton Drive API has started enforcing a restriction that files cannot be created directly at the root of a device; they must be placed inside a folder. + +**Consequence:** The upload is rejected with HTTP 422. The daemon retries when changes happen but the profile is never successfully backed up. Local changes (pinning, adding a sync folder) still take effect locally, but they will not be restorable on a new machine. + +**Planned Fix:** Match the original `RECOVERY.md` spec by putting it in a `.proton-drive-linux` folder within the device root. +- `pdfs-fuse/src/profile.rs:save_profile`: Look up (or create) the `.proton-drive-linux` folder under the device root, and upload `profile.json` there instead. +- `pdfs-fuse/src/profile.rs:load_profile`: Check for `PROFILE_FILE_NAME` inside `.proton-drive-linux` first, falling back to the device root to allow older, existing profiles to be restored until they are overwritten. + +--- + ## B1 — FUSE rename loses the file (data loss) **Status:** Fixed (verified 2026-07-20)