Compare commits

..
7 Commits
Author SHA1 Message Date
nvrl d78ddeab8e cargo fmt 2026-07-31 10:18:30 +02:00
nvrl b04cce3a23 fixed B78 2026-07-31 10:15:13 +02:00
nvrl 7e8403936b fixed permission error on on-demand mounts 2026-07-31 09:46:51 +02:00
nvrl 866a174c7a fixed rpm signing 2026-07-30 02:55:15 +02:00
nvrl 352117e806 fixed rpm signing 2026-07-30 02:23:49 +02:00
nvrl 4b5666eacb added .deb signing + version bump 2026-07-30 02:13:07 +02:00
nvrl 9bbd8eb982 fixed a migration bug where ProtonDrive becomse unwritable 2026-07-30 02:11:23 +02:00
10 changed files with 487 additions and 31 deletions

No files matched your search

+21
View File
@@ -13,6 +13,7 @@ jobs:
build-release:
name: Build and Release
runs-on: ubuntu-24.04
environment: 'rpm signing'
steps:
- name: Checkout repository
@@ -154,6 +155,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 --batch --pinentry-mode loopback --passphrase "$GPG_PASSPHRASE" --import
cat <<EOF > ~/.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
Generated
+4 -4
View File
@@ -3518,7 +3518,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pdfs-cli"
version = "1.2.0"
version = "1.2.1"
dependencies = [
"anyhow",
"clap 4.6.4",
@@ -3533,7 +3533,7 @@ dependencies = [
[[package]]
name = "pdfs-core"
version = "1.2.0"
version = "1.2.1"
dependencies = [
"directories",
"ignore",
@@ -3553,7 +3553,7 @@ dependencies = [
[[package]]
name = "pdfs-fuse"
version = "1.2.0"
version = "1.2.1"
dependencies = [
"fuser",
"image",
@@ -3569,7 +3569,7 @@ dependencies = [
[[package]]
name = "pdfs-gui"
version = "1.2.0"
version = "1.2.1"
dependencies = [
"anyhow",
"async-channel",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package]
version = "1.2.0"
version = "1.2.1"
edition = "2024"
license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"]
+10 -1
View File
@@ -19,9 +19,18 @@ use serde::{Deserialize, Serialize};
use crate::mounts::MountMode;
/// File name of the profile inside the device root folder.
/// File name of the profile.
pub const PROFILE_FILE_NAME: &str = "profile.json";
/// Folder, directly under the device root, that holds the profile.
///
/// The API refuses `Cannot create file at the root of a device` (HTTP 422), so
/// the profile cannot live at the device root itself even though that is where
/// it logically belongs (docs/BUGS.md B68/B78). A dotted name keeps it out of
/// the way in the web UI, and the restore ignores it when listing the device's
/// restorable folders.
pub const PROFILE_DIR_NAME: &str = ".proton-drive-linux";
/// Format version of the profile document.
///
/// Bumped only for changes a *reader* cannot survive. Adding fields does not
+9
View File
@@ -383,6 +383,15 @@ impl Core {
})?
.to_string();
// Syncing a local `.proton-drive-linux` would land on the folder holding
// this device's own profile (`find_device_child_folder` reuses by name),
// and the sync engine would then delete or overwrite it.
if name == pdfs_core::profile::PROFILE_DIR_NAME {
return Err(CoreError::invalid(format!(
"{name} is reserved for this device's profile backup"
)));
}
let device = self.ensure_device()?;
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
+9 -1
View File
@@ -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 {
+137 -14
View File
@@ -13,7 +13,9 @@ use std::sync::atomic::{AtomicBool, Ordering};
use pdfs_core::control::RestorableFolder;
use pdfs_core::mounts::MountMode;
use pdfs_core::profile::{PROFILE_FILE_NAME, PROFILE_VERSION, Profile, ProfileFolder, ProfilePin};
use pdfs_core::profile::{
PROFILE_DIR_NAME, PROFILE_FILE_NAME, PROFILE_VERSION, Profile, ProfileFolder, ProfilePin,
};
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::NodeUid;
use tracing::{info, warn};
@@ -64,8 +66,9 @@ impl Core {
});
}
/// Serialize this machine's arrangement and upload it to `profile.json` in
/// the device root, replacing any existing one.
/// Serialize this machine's arrangement and upload it to `profile.json`
/// inside the device's `.proton-drive-linux` folder, replacing any existing
/// one.
///
/// Does nothing when no device is registered: a machine that has never
/// synced a folder has nothing to describe, and registering a device just to
@@ -86,11 +89,15 @@ impl Core {
let bytes = profile.to_bytes().map_err(CoreError::internal)?;
let len = bytes.len() as i64;
// The device root itself rejects file creation (B78), so the profile
// lives one level down, in a folder created on first save.
let dir_uid = self.ensure_profile_dir(&root_uid)?;
// Replace the existing document rather than adding a second file of the
// same name: the profile is a single mutable record, and a device root
// with `profile.json` and `profile (1).json` in it teaches the restore
// same name: the profile is a single mutable record, and a folder with
// `profile.json` and `profile (1).json` in it teaches the restore
// nothing about which to trust.
let existing = self.find_device_child_file(&root_uid, PROFILE_FILE_NAME)?;
let existing = self.find_device_child_file(&dir_uid, PROFILE_FILE_NAME)?;
match existing {
Some(uid) => self
.rt
@@ -106,7 +113,7 @@ impl Core {
None => {
self.rt
.block_on(self.client.upload_file_replacing_draft_from(
&root_uid,
&dir_uid,
PROFILE_FILE_NAME,
"application/json",
Cursor::new(bytes),
@@ -164,18 +171,18 @@ impl Core {
})
}
/// An untrashed *file* named `name` directly under the device root.
/// An untrashed *file* named `name` directly under `parent_uid`.
///
/// The sibling of `find_device_child_folder`; kept separate because a folder
/// named `profile.json` must not be mistaken for the profile.
fn find_device_child_file(
&self,
root_uid: &NodeUid,
parent_uid: &NodeUid,
name: &str,
) -> CoreResult<Option<NodeUid>> {
let uids = self
.rt
.block_on(self.client.enumerate_folder_children_node_uids(root_uid))
.block_on(self.client.enumerate_folder_children_node_uids(parent_uid))
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
if uids.is_empty() {
return Ok(None);
@@ -190,10 +197,47 @@ impl Core {
.map(|n| n.uid))
}
/// Download and parse the profile in the device root, if there is one.
/// The device's `.proton-drive-linux` folder, creating it if this is the
/// first save on this device.
///
/// Reused by stable identity rather than recreated: a second folder of the
/// same name would split the profile across two documents, and the restore
/// would have no way to tell which is current.
fn ensure_profile_dir(&self, root_uid: &NodeUid) -> CoreResult<NodeUid> {
if let Some(uid) = self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? {
return Ok(uid);
}
let uid = self
.rt
.block_on(
self.client
.create_folder(root_uid, PROFILE_DIR_NAME, Some(now_secs())),
)
.map_err(|e| CoreError::from_api(&e, "create profile folder"))?;
info!(name = PROFILE_DIR_NAME, "created device profile folder");
Ok(uid)
}
/// Download and parse this device's profile, if there is one.
///
/// Looks in `.proton-drive-linux/` first and falls back to the device root,
/// where clients before the B78 fix wrote it. The fallback is read-only: the
/// next save writes to the folder, and the legacy document is left alone
/// rather than trashed, so an older client on another machine keeps working.
fn load_profile(&self, root_uid: &NodeUid) -> CoreResult<Option<Profile>> {
let Some(uid) = self.find_device_child_file(root_uid, PROFILE_FILE_NAME)? else {
return Ok(None);
let current = match self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? {
Some(dir_uid) => self.find_device_child_file(&dir_uid, PROFILE_FILE_NAME)?,
None => None,
};
let uid = match current {
Some(uid) => uid,
None => match self.find_device_child_file(root_uid, PROFILE_FILE_NAME)? {
Some(uid) => {
info!("reading legacy profile from the device root");
uid
}
None => return Ok(None),
},
};
let mut buf: Vec<u8> = Vec::new();
self.rt
@@ -252,7 +296,10 @@ impl Core {
Ok(nodes
.into_iter()
.filter(|n| n.is_folder() && !n.trashed)
// `.proton-drive-linux` holds the profile itself, not user data:
// offering it as a restorable folder would sync the bookkeeping
// back onto the machine it describes.
.filter(|n| n.is_folder() && !n.trashed && n.name != PROFILE_DIR_NAME)
.map(|n| {
let uid = n.uid.to_string();
let saved = profile
@@ -402,6 +449,82 @@ mod tests {
assert_eq!(restore_mode("unknown"), None);
}
/// The device root refuses file creation (B78), so every upload in
/// `save_profile` must be addressed to the profile folder. Checked against
/// the source because both branches are pure network calls.
#[test]
fn the_profile_is_never_uploaded_to_the_device_root() {
let source = include_str!("profile.rs");
let save = function_source(source, "fn save_profile(");
assert!(
save.contains("ensure_profile_dir(&root_uid)"),
"save_profile resolves the profile folder"
);
for upload in [
"upload_new_revision_from",
"upload_file_replacing_draft_from",
] {
let call = save.find(upload).expect("upload call exists");
let args = &save[call..];
let root = args.find("&root_uid").unwrap_or(usize::MAX);
let dir = args.find("&dir_uid").unwrap_or(usize::MAX);
assert!(dir < root, "{upload} must target the profile folder");
}
}
/// A read has to keep finding profiles written before the fix, which sat in
/// the device root.
#[test]
fn loading_falls_back_to_a_legacy_device_root_profile() {
let source = include_str!("profile.rs");
let load = function_source(source, "fn load_profile(");
assert!(load.contains("PROFILE_DIR_NAME"), "prefers the folder");
assert!(
load.find("PROFILE_DIR_NAME").unwrap()
< load.find("legacy profile").expect("legacy fallback exists"),
"the folder is consulted before the device root"
);
}
/// The profile folder is the client's own bookkeeping: neither the restore
/// picker nor `add_sync_folder` may treat it as a user folder.
#[test]
fn the_profile_folder_is_not_offered_as_user_data() {
let restorable = function_source(include_str!("profile.rs"), "fn list_restorable_folders(");
assert!(
restorable.contains("n.name != PROFILE_DIR_NAME"),
"restorable folders exclude the profile folder"
);
let add = function_source(include_str!("devices.rs"), "fn add_sync_folder(");
assert_before(add, "PROFILE_DIR_NAME", "ensure_device()");
}
fn function_source<'a>(source: &'a str, signature: &str) -> &'a str {
let start = source.find(signature).expect("function signature exists");
let body = &source[start..];
let open = body.find('{').expect("function body starts");
let mut depth = 0usize;
for (offset, byte) in body.as_bytes()[open..].iter().enumerate() {
match byte {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
return &body[..open + offset + 1];
}
}
_ => {}
}
}
panic!("function body closes")
}
fn assert_before(source: &str, first: &str, second: &str) {
let first_at = source.find(first).expect("first operation exists");
let second_at = source.find(second).expect("second operation exists");
assert!(first_at < second_at, "{first} must precede {second}");
}
/// A path whose parent exists but which does not itself is exactly the
/// restore case — the directory is about to be created.
#[test]
+168 -8
View File
@@ -247,6 +247,17 @@ impl State {
self.access_changes.drain().collect()
}
/// Whether `uid` lives on the volume this mount is rooted in.
///
/// Used to decide how a node with no resolvable parent is classified:
/// our own volume fails open (it is owned content), a foreign volume
/// fails closed (it can only be there because of a share).
pub(crate) fn is_own_volume(&self, uid: &NodeUid) -> bool {
self.entries
.get(&crate::ROOT_INO)
.is_some_and(|root| root.uid.volume_id == uid.volume_id)
}
fn stored_share_access(&self, uid: &NodeUid) -> Option<Access> {
self.share_access.get(uid).copied()
}
@@ -448,8 +459,13 @@ impl State {
let roots: Vec<u64> = 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,17 +510,35 @@ 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) {
// The parent is not resident, so there is nothing to inherit
// from. This is the normal shape of a device-folder root: its
// parent is the device root, which is never persisted as a
// node, so every on-demand folder hydrates parentless here.
// Answer the way the persisted authority (`effective_node_access`)
// does — a recorded share row above it if we have one, otherwise
// fail open on our own volume and closed on a foreign one.
// Failing closed on our own volume denies every write in every
// secondary mount, because `require_uid_writable` intersects
// this state with the mount that actually owns the node.
entry
.node
.parent_uid
.as_ref()
.and_then(|parent_uid| state.share_access.get(parent_uid).copied())
.unwrap_or(Access::Unknown)
.unwrap_or_else(|| {
if state.is_own_volume(&entry.uid) {
Access::Owner
} else {
Access::Unknown
}
})
} else {
let parent_access = resolve(state, entry.parent, memo, visiting);
if entry.node.membership.is_none() || parent_access != Access::Owner {
@@ -538,8 +572,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)
@@ -1250,6 +1288,128 @@ mod tests {
assert!(st.require_writable(owned_root).is_ok());
}
/// Materialize `nodes` the way `Core::hydrate` does: allocate every inode
/// first, then insert entries with `Access::Unknown`, resolving each parent
/// by uid and parking a node whose parent never made it to disk at
/// `ORPHAN_INO`. `nodes[0]` becomes the mount root.
fn hydrate_from(st: &mut State, nodes: &[Node]) -> Vec<u64> {
for node in nodes {
st.db.upsert_node(node).unwrap();
}
let inos: Vec<u64> = nodes
.iter()
.enumerate()
.map(|(i, node)| {
let ino = crate::ROOT_INO + i as u64;
st.by_uid.insert(node.uid.clone(), ino);
ino
})
.collect();
st.next_ino = crate::ROOT_INO + nodes.len() as u64;
// Reverse order so entries are inserted before their parents, matching
// the hydration path's non-topological materialization.
for (i, node) in nodes.iter().enumerate().rev() {
let ino = inos[i];
let parent = if ino == crate::ROOT_INO {
crate::ROOT_INO
} else {
node.parent_uid
.as_ref()
.and_then(|p| st.by_uid.get(p).copied())
.unwrap_or(crate::ORPHAN_INO)
};
st.entries.insert(
ino,
Entry {
uid: node.uid.clone(),
parent,
node: node.clone(),
access: Access::Unknown,
lookup_count: 1,
open_count: 0,
unlinked: false,
},
);
}
st.hydrate_access();
inos
}
/// A device folder's parent is the device root, which is never persisted as
/// a node, so the folder hydrates with no resident parent. Classifying that
/// as `Unknown` denied every write in every on-demand mount: the mount's own
/// fork says `Owner`, but `require_uid_writable` intersects it with this
/// state, and the intersection is what reaches the queue guards.
#[test]
fn own_volume_nodes_without_a_resident_parent_stay_owned() {
let (mut st, _dir) = state();
let nodes = [
node("root", "none", "My Files", true),
// `device-root` is deliberately absent from `nodes`.
node("documents", "device-root", "Documents", true),
node("folder", "documents", "folder", true),
node("leaf", "folder", "leaf.txt", false),
];
let inos = hydrate_from(&mut st, &nodes);
for ino in inos {
assert_eq!(st.entries[&ino].access, Access::Owner);
assert!(st.require_writable(ino).is_ok());
}
}
/// The same shape on a foreign volume can only be there because of a share,
/// so it keeps failing closed.
#[test]
fn foreign_volume_nodes_without_a_resident_parent_fail_closed() {
let (mut st, _dir) = state();
let foreign = |link: &str, parent: &str, name: &str, is_dir: bool| {
let mut node = node(link, parent, name, is_dir);
node.uid = NodeUid::new(VolumeId::from("other-vol"), LinkId::from(link));
node.parent_uid = Some(NodeUid::new(
VolumeId::from("other-vol"),
LinkId::from(parent),
));
node
};
let nodes = [
node("root", "none", "My Files", true),
// `their-share` is absent, so the subtree has no resident parent.
foreign("their-folder", "their-share", "Team Budget", true),
foreign("their-leaf", "their-folder", "budget.ods", false),
];
let inos = hydrate_from(&mut st, &nodes);
assert_eq!(st.entries[&inos[0]].access, Access::Owner);
for ino in &inos[1..] {
assert_eq!(st.entries[ino].access, Access::Unknown);
assert_eq!(st.require_writable(*ino).unwrap_err().code(), libc::EACCES);
}
}
/// Fail-open is only for the absence of a recorded authority: a share row on
/// the missing parent still governs the subtree below it.
#[test]
fn persisted_authority_outranks_the_own_volume_fail_open() {
let (mut st, _dir) = state();
st.db
.set_share_access(&uid("device-root"), Access::Viewer)
.unwrap();
st.share_access = st.db.all_share_access().unwrap();
let nodes = [
node("root", "none", "My Files", true),
node("documents", "device-root", "Documents", true),
node("leaf", "documents", "leaf.txt", false),
];
let inos = hydrate_from(&mut st, &nodes);
assert_eq!(st.entries[&inos[0]].access, Access::Owner);
for ino in &inos[1..] {
assert_eq!(st.entries[ino].access, Access::Viewer);
assert_eq!(st.require_writable(*ino).unwrap_err().code(), libc::EACCES);
}
}
#[test]
fn reinterned_share_root_propagates_a_viewer_downgrade() {
let (mut st, _dir) = state();
+127 -1
View File
@@ -12,6 +12,130 @@ Conventions:
---
## B79 — Every write in an on-demand device-folder mount fails EACCES
**Status:** Fixed (verified live 2026-07-31, running in a locally rebuilt 1.2.1
package since)
**Found:** 2026-07-31, user on packaged 1.2.1: `touch ~/Documents/test` →
`Permission denied`, while the same operation under `~/ProtonDrive` succeeds.
**Where:** `crates/pdfs-fuse/src/state.rs`, `State::hydrate_access`
Not a mode-bit problem — the mount root reads `drwxr-xr-x` and the kernel's
`default_permissions` check passes. The denial comes from the queue guard:
`serve_create` → `Core::require_uid_writable`, which **intersects the access
every live inode space reports for that uid** (`lib.rs:721`) before anything
reaches `pending_op`.
The on-demand fork itself says `Owner`. The **primary** state says `Unknown`:
- `Core::hydrate` (`lib.rs:1012`) loads *every* `nodes` row into the primary
state, including the subtrees that belong to on-demand device folders — they
are on the same volume, in the same table.
- A device folder's `parent_uid` is the **device root**, which is never
persisted as a node. In this account's DB all six device-folder roots
(`Downloads`, `Pictures`, `Music`, `Documents`, `Videos`, `narl`) have
`parent_uid = <device root>` with no matching row, so pass 2 parks them at
`ORPHAN_INO`.
- `hydrate_access` then hit its "parent is not resident" branch, which had no
answer and fell back to `Access::Unknown` — fail closed.
- `Unknown` is not writable, so the intersection denies every create, mkdir,
write, rename, setattr and trash in every on-demand mount, permanently: those
entries are never re-interned in the primary state, so nothing recomputes them.
`~/ProtonDrive` was unaffected because its nodes descend from `ROOT_INO`, which
`ProtonFs::new` hardcodes to `Owner`.
Two facts make this a *classification* bug rather than a policy one: the
persisted authority `Db::effective_node_access` already answers `Owner` for
exactly these uids (no `share_access` row covers them — the table holds only
`virtual~sharedwithme` and one accepted share), and the design table in
`mount-architecture.md` §2.2 says *not under a share, no role → Owner, fail
open*. The in-memory path disagreed with both.
**Fix:** the non-resident-parent branch now mirrors the persisted authority — a
recorded `share_access` row above the node still wins, otherwise fail **open**
to `Owner` on the mount's own volume and fail **closed** to `Unknown` on a
foreign one (`State::is_own_volume`). Foreign-volume shared content keeps its
fail-closed behavior, which is what B34 needs.
**Second-order effect, also fixed by the same change:** `downgrade_known_shared_access`
selects roots with `entry.access != Access::Owner`, so a `ScopeAccessLost` /
`SharedWithMeUpdated` event would additionally have force-downgraded every
device-folder tree to `Viewer` in memory.
**Tests:** `state::tests::own_volume_nodes_without_a_resident_parent_stay_owned`,
`foreign_volume_nodes_without_a_resident_parent_fail_closed`,
`persisted_authority_outranks_the_own_volume_fail_open`. The first fails on the
pre-fix branch with `left: Unknown, right: Owner`.
**Verified live (2026-07-31):** the packaged 1.2.1 daemon denies
`touch ~/Documents/pdfs-b79-test`, `mkdir`, and `>` redirect while
`~/ProtonDrive` accepts them; a local `--release` build of this fix accepts all
three on the same account and DB, the write drains (`pending_op` back to 0, the
new nodes carry real `G88km…` remote uids rather than local ones), and the
daemon log has no `403`/EACCES. `~/ProtonDrive/Shared with me/` stayed
`dr-xr-xr-x`, and the accepted editor-role share under it stayed writable — the
foreign-volume fail-closed path is unchanged.
**Side note for whoever runs this next:** stopping the daemon and touching a
file in an on-demand *mountpoint* while it is unmounted leaves that file in the
local directory, and `restore_ondemand_mounts` then refuses to mount over the
non-empty dir (`WARN … local dir is not empty; refusing to mount over it`).
Empty the directory and restart the unit.
---
## B78 — Profile backup fails: Cannot create file at the root of a device
**Status:** Fixed (verified live 2026-07-31). Same defect as **B68**, which was
found first on the managed-live matrix; B68's second half — making backup health
visible — is still 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.
**Fix applied (matches the original `RECOVERY.md` spec):** the profile now lives
in a `.proton-drive-linux` folder inside the device root.
- `pdfs_core::profile::PROFILE_DIR_NAME` — the new constant, next to
`PROFILE_FILE_NAME`.
- `profile.rs:ensure_profile_dir` — reuses the folder by name via the existing
`find_device_child_folder`, creating it on the first save. Reuse-by-identity
matters: a second folder of the same name would split the record in two with
nothing to say which is current.
- `profile.rs:save_profile` — both upload branches (new file, new revision) now
address the folder, never the device root.
- `profile.rs:load_profile` — reads the folder first, then falls back to a
device-root `profile.json` written by an older client. The fallback is
read-only and the legacy document is **not** trashed: another machine may
still be running a pre-fix client that reads it.
- `profile.rs:list_restorable_folders` — filters `.proton-drive-linux` out, or
the restore picker would offer to sync the bookkeeping back onto the machine
it describes.
- `devices.rs:add_sync_folder` — refuses a local folder named
`.proton-drive-linux`, which would otherwise be reused as the remote profile
folder by name and then reconciled (i.e. overwritten) by the sync engine.
**Verified live (2026-07-31)** against the real account: first save logged
`created device profile folder name=".proton-drive-linux"` then
`profile backed up folders=6` with no 422; a second trigger reused the folder
(no second create) and uploaded a revision; `pdfs sync restore --json` lists
exactly the six device folders and not the profile folder.
**Tests:** `profile::tests::the_profile_is_never_uploaded_to_the_device_root`,
`loading_falls_back_to_a_legacy_device_root_profile`,
`the_profile_folder_is_not_offered_as_user_data` — source-level guards in the
style of `lib.rs`'s queue-guard ordering tests, because both upload branches are
pure network calls with nothing to assert offline.
**Not covered by this fix:** B68 also asks for backup *health* to be visible
(currently a `WARN` in the log and nothing in the UI), and for the
replacement-machine / fresh-state restore to be re-run end to end.
---
## B1 — FUSE rename loses the file (data loss)
**Status:** Fixed (verified 2026-07-20)
@@ -2105,7 +2229,9 @@ duplicates, renamed folders, multiple devices, and byte-identical restoration.
## B68 — Profile Backup Cannot Be Written to the Device Root
**Status:** Open — live-reproduced 2026-07-22
**Status:** Partly fixed — the destination is fixed and verified live
(2026-07-31, see **B78**); backup health is still invisible in the UI and the
replacement-machine restore has not been re-run
**Found:** complete managed-live mode matrix against the 1.0.0 release binary
**Where:** `crates/pdfs-fuse/src/profile.rs`, profile backup destination
+1 -1
View File
@@ -9,7 +9,7 @@
# Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux
pkgver=1.2.0
pkgver=1.2.1
pkgrel=1
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
arch=('x86_64')