mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-07 17:12:10 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d78ddeab8e | ||
|
|
b04cce3a23 | ||
|
|
7e8403936b | ||
|
|
866a174c7a | ||
|
|
352117e806 | ||
|
|
4b5666eacb | ||
|
|
9bbd8eb982 |
No files matched your search
@@ -13,6 +13,7 @@ jobs:
|
|||||||
build-release:
|
build-release:
|
||||||
name: Build and Release
|
name: Build and Release
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
|
environment: 'rpm signing'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -154,6 +155,26 @@ jobs:
|
|||||||
# Copy built RPM to root directory
|
# Copy built RPM to root directory
|
||||||
cp rpmbuild/RPMS/x86_64/*.rpm .
|
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
|
- name: Upload Workflow Artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
Generated
+4
-4
@@ -3518,7 +3518,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "1.2.0"
|
version = "1.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap 4.6.4",
|
"clap 4.6.4",
|
||||||
@@ -3533,7 +3533,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "1.2.0"
|
version = "1.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
"ignore",
|
"ignore",
|
||||||
@@ -3553,7 +3553,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "1.2.0"
|
version = "1.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fuser",
|
"fuser",
|
||||||
"image",
|
"image",
|
||||||
@@ -3569,7 +3569,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "1.2.0"
|
version = "1.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.2.0"
|
version = "1.2.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
|
|||||||
@@ -19,9 +19,18 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::mounts::MountMode;
|
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";
|
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.
|
/// Format version of the profile document.
|
||||||
///
|
///
|
||||||
/// Bumped only for changes a *reader* cannot survive. Adding fields does not
|
/// Bumped only for changes a *reader* cannot survive. Adding fields does not
|
||||||
|
|||||||
@@ -383,6 +383,15 @@ impl Core {
|
|||||||
})?
|
})?
|
||||||
.to_string();
|
.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 device = self.ensure_device()?;
|
||||||
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
|
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
|
||||||
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
||||||
|
|||||||
@@ -992,7 +992,15 @@ impl ProtonFs {
|
|||||||
warn!(uid = %root.uid, error = %e, "db upsert root failed");
|
warn!(uid = %root.uid, error = %e, "db upsert root failed");
|
||||||
}
|
}
|
||||||
st.by_uid.insert(root.uid.clone(), ROOT_INO);
|
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(
|
st.entries.insert(
|
||||||
ROOT_INO,
|
ROOT_INO,
|
||||||
Entry {
|
Entry {
|
||||||
|
|||||||
+137
-14
@@ -13,7 +13,9 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||||||
|
|
||||||
use pdfs_core::control::RestorableFolder;
|
use pdfs_core::control::RestorableFolder;
|
||||||
use pdfs_core::mounts::MountMode;
|
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 pdfs_core::{CoreError, CoreResult};
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
@@ -64,8 +66,9 @@ impl Core {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize this machine's arrangement and upload it to `profile.json` in
|
/// Serialize this machine's arrangement and upload it to `profile.json`
|
||||||
/// the device root, replacing any existing one.
|
/// inside the device's `.proton-drive-linux` folder, replacing any existing
|
||||||
|
/// one.
|
||||||
///
|
///
|
||||||
/// Does nothing when no device is registered: a machine that has never
|
/// 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
|
/// 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 bytes = profile.to_bytes().map_err(CoreError::internal)?;
|
||||||
let len = bytes.len() as i64;
|
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
|
// 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
|
// same name: the profile is a single mutable record, and a folder with
|
||||||
// with `profile.json` and `profile (1).json` in it teaches the restore
|
// `profile.json` and `profile (1).json` in it teaches the restore
|
||||||
// nothing about which to trust.
|
// 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 {
|
match existing {
|
||||||
Some(uid) => self
|
Some(uid) => self
|
||||||
.rt
|
.rt
|
||||||
@@ -106,7 +113,7 @@ impl Core {
|
|||||||
None => {
|
None => {
|
||||||
self.rt
|
self.rt
|
||||||
.block_on(self.client.upload_file_replacing_draft_from(
|
.block_on(self.client.upload_file_replacing_draft_from(
|
||||||
&root_uid,
|
&dir_uid,
|
||||||
PROFILE_FILE_NAME,
|
PROFILE_FILE_NAME,
|
||||||
"application/json",
|
"application/json",
|
||||||
Cursor::new(bytes),
|
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
|
/// The sibling of `find_device_child_folder`; kept separate because a folder
|
||||||
/// named `profile.json` must not be mistaken for the profile.
|
/// named `profile.json` must not be mistaken for the profile.
|
||||||
fn find_device_child_file(
|
fn find_device_child_file(
|
||||||
&self,
|
&self,
|
||||||
root_uid: &NodeUid,
|
parent_uid: &NodeUid,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> CoreResult<Option<NodeUid>> {
|
) -> CoreResult<Option<NodeUid>> {
|
||||||
let uids = self
|
let uids = self
|
||||||
.rt
|
.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"))?;
|
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
||||||
if uids.is_empty() {
|
if uids.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -190,10 +197,47 @@ impl Core {
|
|||||||
.map(|n| n.uid))
|
.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>> {
|
fn load_profile(&self, root_uid: &NodeUid) -> CoreResult<Option<Profile>> {
|
||||||
let Some(uid) = self.find_device_child_file(root_uid, PROFILE_FILE_NAME)? else {
|
let current = match self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? {
|
||||||
return Ok(None);
|
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();
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
self.rt
|
self.rt
|
||||||
@@ -252,7 +296,10 @@ impl Core {
|
|||||||
|
|
||||||
Ok(nodes
|
Ok(nodes
|
||||||
.into_iter()
|
.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| {
|
.map(|n| {
|
||||||
let uid = n.uid.to_string();
|
let uid = n.uid.to_string();
|
||||||
let saved = profile
|
let saved = profile
|
||||||
@@ -402,6 +449,82 @@ mod tests {
|
|||||||
assert_eq!(restore_mode("unknown"), None);
|
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
|
/// A path whose parent exists but which does not itself is exactly the
|
||||||
/// restore case — the directory is about to be created.
|
/// restore case — the directory is about to be created.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -247,6 +247,17 @@ impl State {
|
|||||||
self.access_changes.drain().collect()
|
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> {
|
fn stored_share_access(&self, uid: &NodeUid) -> Option<Access> {
|
||||||
self.share_access.get(uid).copied()
|
self.share_access.get(uid).copied()
|
||||||
}
|
}
|
||||||
@@ -448,8 +459,13 @@ impl State {
|
|||||||
let roots: Vec<u64> = self
|
let roots: Vec<u64> = self
|
||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|entry| {
|
.filter(|&(&ino, entry)| {
|
||||||
let (_, entry) = 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.access != Access::Owner
|
||||||
|| entry.node.membership.is_some()
|
|| entry.node.membership.is_some()
|
||||||
|| self.share_access.contains_key(&entry.uid)
|
|| self.share_access.contains_key(&entry.uid)
|
||||||
@@ -494,17 +510,35 @@ impl State {
|
|||||||
return Access::Unknown;
|
return Access::Unknown;
|
||||||
}
|
}
|
||||||
let stored = state.share_access.get(&entry.uid).copied();
|
let stored = state.share_access.get(&entry.uid).copied();
|
||||||
let access = if let Some(stored) = stored {
|
// The root of this mount is always owned, regardless of any
|
||||||
stored
|
// stale share_access row that a global downgrade may have left.
|
||||||
} else if entry.parent == ino {
|
let access = if entry.parent == ino {
|
||||||
Access::Owner
|
Access::Owner
|
||||||
|
} else if let Some(stored) = stored {
|
||||||
|
stored
|
||||||
} else if !state.entries.contains_key(&entry.parent) {
|
} 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
|
entry
|
||||||
.node
|
.node
|
||||||
.parent_uid
|
.parent_uid
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|parent_uid| state.share_access.get(parent_uid).copied())
|
.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 {
|
} else {
|
||||||
let parent_access = resolve(state, entry.parent, memo, visiting);
|
let parent_access = resolve(state, entry.parent, memo, visiting);
|
||||||
if entry.node.membership.is_none() || parent_access != Access::Owner {
|
if entry.node.membership.is_none() || parent_access != Access::Owner {
|
||||||
@@ -538,8 +572,12 @@ impl State {
|
|||||||
}
|
}
|
||||||
let discovered: Vec<(NodeUid, Access)> = self
|
let discovered: Vec<(NodeUid, Access)> = self
|
||||||
.entries
|
.entries
|
||||||
.values()
|
.iter()
|
||||||
.filter_map(|entry| {
|
.filter_map(|(&ino, entry)| {
|
||||||
|
// The filesystem root is owned, not a discovered share root.
|
||||||
|
if entry.parent == ino {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let parent_access = self
|
let parent_access = self
|
||||||
.entries
|
.entries
|
||||||
.get(&entry.parent)
|
.get(&entry.parent)
|
||||||
@@ -1250,6 +1288,128 @@ mod tests {
|
|||||||
assert!(st.require_writable(owned_root).is_ok());
|
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]
|
#[test]
|
||||||
fn reinterned_share_root_propagates_a_viewer_downgrade() {
|
fn reinterned_share_root_propagates_a_viewer_downgrade() {
|
||||||
let (mut st, _dir) = state();
|
let (mut st, _dir) = state();
|
||||||
|
|||||||
+127
-1
@@ -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)
|
## B1 — FUSE rename loses the file (data loss)
|
||||||
|
|
||||||
**Status:** Fixed (verified 2026-07-20)
|
**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
|
## 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
|
**Found:** complete managed-live mode matrix against the 1.0.0 release binary
|
||||||
**Where:** `crates/pdfs-fuse/src/profile.rs`, profile backup destination
|
**Where:** `crates/pdfs-fuse/src/profile.rs`, profile backup destination
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
# Build + install: cd packaging && makepkg -fi
|
# Build + install: cd packaging && makepkg -fi
|
||||||
|
|
||||||
pkgname=proton-drive-linux
|
pkgname=proton-drive-linux
|
||||||
pkgver=1.2.0
|
pkgver=1.2.1
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
Reference in new issue
Block a user