mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +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:
|
||||
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
@@ -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
@@ -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>"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user