Skip to content

Commit 618e39d

Browse files
committed
feat: better permission error handling
1 parent d1f6e31 commit 618e39d

8 files changed

Lines changed: 372 additions & 2 deletions

File tree

.goreleaser.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ nfpms:
6565
conflicts:
6666
- adfinis-rclone-mount
6767
contents:
68+
- src: ./assets/adfinis-rclone-mgr@.service
69+
dst: /usr/lib/systemd/user/adfinis-rclone-mgr@.service
6870
- src: ./assets/rclone@.service
6971
dst: /usr/lib/systemd/user/rclone@.service
7072
- src: ./assets/google_drive_opener.py

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ This repository provides a streamlined way to mount Google Drive using Rclone, t
1212
- Systemd service templates for managing Rclone mounts.
1313
- Nautilus integration for opening files directly in Google Drive and copying shareable links.
1414
- CLI Tool to mount and umount shares
15+
- Better error handling in case of permission errors
1516

1617
## 📦 Installation
1718

@@ -47,6 +48,7 @@ This repository provides a streamlined way to mount Google Drive using Rclone, t
4748
4. Install the assets:
4849
```bash
4950
sudo cp assets/rclone@.service /usr/lib/systemd/user/
51+
sudo cp assets/adfinis-rclone-mgr@.service /usr/lib/systemd/user/
5052
sudo cp assets/google_drive_opener.py /usr/share/nautilus-python/extensions/
5153
sudo cp assets/adfinis-rclone-mgr.desktop /usr/share/applications/
5254
sudo cp assets/adfinis-rclone-mgr.png /usr/share/icons/hicolor/512x512/apps/

assets/adfinis-rclone-mgr@.service

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[Unit]
2+
Description=adfinis-rclone-mgr journald reader for %I
3+
After=rclone@%i.service
4+
PartOf=rclone@%i.service
5+
6+
[Service]
7+
Type=simple
8+
ExecStart=/usr/bin/adfinis-rclone-mgr journald-reader %I
9+
Restart=on-failure
10+
11+
[Install]
12+
WantedBy=default.target

assets/rclone@.service

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ Description=rclone: Remote FUSE filesystem for cloud storage config %I
33
Documentation=man:rclone(1)
44
After=network-online.target
55
Wants=network-online.target
6+
Wants=adfinis-rclone-mgr@%i.service
67
AssertPathIsDirectory="%h/google/%I"
78

89
[Service]

journald.go

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
package main
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"os"
10+
"os/exec"
11+
"path"
12+
"strings"
13+
14+
"github.com/adrg/xdg"
15+
"github.com/spf13/cobra"
16+
)
17+
18+
// LogEntry represents a parsed journalctl log line
19+
type LogEntry struct {
20+
Message string `json:"MESSAGE"`
21+
Timestamp string `json:"__REALTIME_TIMESTAMP"`
22+
Priority string `json:"PRIORITY"`
23+
Unit string `json:"_SYSTEMD_UNIT"`
24+
}
25+
26+
func journaldReader(cmd *cobra.Command, args []string) {
27+
ctx := cmd.Context()
28+
driveName := args[0]
29+
logs, errs := startJournalReader(ctx, driveName)
30+
31+
go func() {
32+
for l := range logs {
33+
handleLogEntry(l, driveName)
34+
}
35+
}()
36+
37+
go func() {
38+
for err := range errs {
39+
fmt.Printf("Error: %v\n", err)
40+
}
41+
}()
42+
43+
<-ctx.Done()
44+
}
45+
46+
func startJournalReader(ctx context.Context, name string) (<-chan LogEntry, <-chan error) {
47+
logs := make(chan LogEntry)
48+
errs := make(chan error, 1)
49+
50+
cmd := exec.Command(
51+
"journalctl",
52+
"--output=json",
53+
"--follow",
54+
"--user",
55+
"--since=now",
56+
fmt.Sprintf("--unit=%s", driveNameToUnitName(name)),
57+
)
58+
stdout, err := cmd.StdoutPipe()
59+
if err != nil {
60+
errs <- fmt.Errorf("failed to get stdout: %w", err)
61+
close(logs)
62+
close(errs)
63+
return logs, errs
64+
}
65+
66+
if err := cmd.Start(); err != nil {
67+
errs <- fmt.Errorf("failed to start journalctl: %w", err)
68+
close(logs)
69+
close(errs)
70+
return logs, errs
71+
}
72+
73+
go func() {
74+
defer close(logs)
75+
defer close(errs)
76+
77+
scanner := bufio.NewScanner(stdout)
78+
for scanner.Scan() {
79+
select {
80+
case <-ctx.Done():
81+
_ = cmd.Process.Kill()
82+
return
83+
default:
84+
var entry LogEntry
85+
line := scanner.Text()
86+
if err := json.Unmarshal([]byte(line), &entry); err != nil {
87+
errs <- fmt.Errorf("failed to parse log line: %w", err)
88+
continue
89+
}
90+
logs <- entry
91+
}
92+
}
93+
94+
if err := scanner.Err(); err != nil && err != io.EOF {
95+
errs <- fmt.Errorf("scanner error: %w", err)
96+
}
97+
98+
if err := cmd.Wait(); err != nil {
99+
errs <- fmt.Errorf("journalctl command error: %w", err)
100+
}
101+
}()
102+
103+
return logs, errs
104+
}
105+
106+
func handleLogEntry(entry LogEntry, driveName string) {
107+
if !strings.Contains(entry.Message, "insufficientParentPermissions") {
108+
return
109+
}
110+
111+
fileName := strings.TrimSpace(strings.SplitN(entry.Message, ":", 3)[1])
112+
filePath := fileNameToPath(driveName, fileName)
113+
114+
// make sure file still exists
115+
if _, err := os.Stat(filePath); err != nil {
116+
return
117+
}
118+
119+
title := fmt.Sprintf("Drive Error: %s", driveName)
120+
message := fmt.Sprintf(`You have insufficient permissions to write a file:
121+
122+
- %s
123+
124+
Make sure to move the file you just created to another location immediately!
125+
`, path.Join(getDriveDataPath(driveName), filePath))
126+
if err := sendDesktopNotificationError(title, message); err != nil {
127+
fmt.Printf("Failed to send notification: %v\n", err)
128+
}
129+
130+
// open file selector to select the file location
131+
title = "Select File Location"
132+
message = fmt.Sprintf("Select a new location for the file:\n\n%s", filePath)
133+
134+
// open the file selector in the "overview" folder with all other drives
135+
suggestedPath := path.Join(xdg.Home, "google", path.Base(filePath))
136+
fmt.Println(suggestedPath)
137+
newFilePath, err := openFileSelector(title, message, suggestedPath)
138+
if err != nil {
139+
if strings.Contains(err.Error(), "exit status 1") {
140+
fmt.Println("File selector was cancelled, skipping...")
141+
return
142+
}
143+
fmt.Printf("Failed to open file selector: %v\n", err)
144+
return
145+
}
146+
if newFilePath == "" {
147+
fmt.Println("No file selected, skipping...")
148+
return
149+
}
150+
if err := moveFile(filePath, newFilePath); err != nil {
151+
title = "Error Moving File"
152+
message = fmt.Sprintf("Failed to move file:\n\n%s", err)
153+
if err := sendDesktopNotificationError(title, message); err != nil {
154+
fmt.Printf("Failed to send notification: %v\n", err)
155+
}
156+
fmt.Printf("Failed to move file: %v\n", err)
157+
return
158+
}
159+
title = "File Moved"
160+
message = fmt.Sprintf("File moved to:\n\n%s", newFilePath)
161+
if err := sendDesktopNotificationInfo(title, message); err != nil {
162+
fmt.Printf("Failed to send notification: %v\n", err)
163+
}
164+
fmt.Printf("File %q moved to: %s\n", filePath, newFilePath)
165+
}
166+
167+
// moveFile moves a file from oldPath to newPath by copying the file and removing the old file.
168+
// This is a workaround for the issue where os.Rename fails across different filesystems (and rclone mounts).
169+
func moveFile(src, dest string) error {
170+
in, err := os.Open(src)
171+
if err != nil {
172+
return err
173+
}
174+
defer in.Close() // nolint:errcheck
175+
176+
info, err := in.Stat()
177+
if err != nil {
178+
return err
179+
}
180+
181+
// create new file. This will overwrite the file if it exists
182+
out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
183+
if err != nil {
184+
if strings.Contains(err.Error(), "is a directory") {
185+
return fmt.Errorf("Destination %q is a directory.\n\nYou cant replace a file with a directory!", dest) // nolint:staticcheck
186+
}
187+
return err
188+
}
189+
defer out.Close() // nolint:errcheck
190+
191+
_, err = io.Copy(out, in)
192+
if err != nil {
193+
return err
194+
}
195+
196+
// Close the files before removing
197+
in.Close() // nolint:errcheck
198+
out.Close() // nolint:errcheck
199+
200+
// Remove the original file
201+
if err = os.Remove(src); err != nil {
202+
return err
203+
}
204+
205+
return nil
206+
}
207+
208+
func sendDesktopNotificationError(title, message string) error {
209+
cmd := exec.Command("zenity", "--error", "--text", message, "--title", title)
210+
if err := cmd.Run(); err != nil {
211+
return fmt.Errorf("failed to send notification: %w", err)
212+
}
213+
return nil
214+
}
215+
216+
func sendDesktopNotificationInfo(title, message string) error {
217+
cmd := exec.Command("zenity", "--info", "--text", message, "--title", title)
218+
if err := cmd.Run(); err != nil {
219+
return fmt.Errorf("failed to send notification: %w", err)
220+
}
221+
return nil
222+
}
223+
224+
func openFileSelector(title, message, fileName string) (string, error) {
225+
cmd := exec.Command(
226+
"zenity",
227+
"--file-selection",
228+
"--save",
229+
"--confirm-overwrite",
230+
"--title", title,
231+
"--text", message,
232+
"--filename", fileName,
233+
)
234+
output, err := cmd.Output()
235+
if err != nil {
236+
return "", fmt.Errorf("failed to open file selector: %w", err)
237+
}
238+
return strings.TrimSpace(string(output)), nil
239+
}

journald_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package main
2+
3+
import (
4+
"io/fs"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
)
12+
13+
func TestMoveFile_Success(t *testing.T) {
14+
dir := t.TempDir()
15+
src := filepath.Join(dir, "src.txt")
16+
dest := filepath.Join(dir, "dest.txt")
17+
content := []byte("hello world")
18+
19+
err := os.WriteFile(src, content, 0644)
20+
assert.NoError(t, err)
21+
22+
err = moveFile(src, dest)
23+
assert.NoError(t, err)
24+
25+
// Source should not exist
26+
_, err = os.Stat(src)
27+
assert.True(t, os.IsNotExist(err))
28+
29+
// Dest should exist and have correct content
30+
got, err := os.ReadFile(dest)
31+
assert.NoError(t, err)
32+
assert.Equal(t, content, got)
33+
34+
// Permissions should be preserved
35+
info, err := os.Stat(dest)
36+
assert.NoError(t, err)
37+
assert.Equal(t, fs.FileMode(0644), info.Mode().Perm())
38+
}
39+
40+
func TestMoveFile_OverwriteExistingFile(t *testing.T) {
41+
dir := t.TempDir()
42+
src := filepath.Join(dir, "src.txt")
43+
dest := filepath.Join(dir, "dest.txt")
44+
45+
err := os.WriteFile(src, []byte("src data"), 0600)
46+
assert.NoError(t, err)
47+
err = os.WriteFile(dest, []byte("old dest data"), 0644)
48+
assert.NoError(t, err)
49+
50+
err = moveFile(src, dest)
51+
assert.NoError(t, err)
52+
53+
// Source should not exist
54+
_, err = os.Stat(src)
55+
assert.True(t, os.IsNotExist(err))
56+
57+
// Dest should have new content
58+
got, err := os.ReadFile(dest)
59+
assert.NoError(t, err)
60+
assert.Equal(t, []byte("src data"), got)
61+
}
62+
63+
func TestMoveFile_DestIsDirectory(t *testing.T) {
64+
dir := t.TempDir()
65+
src := filepath.Join(dir, "src.txt")
66+
destDir := filepath.Join(dir, "destdir")
67+
68+
err := os.WriteFile(src, []byte("data"), 0644)
69+
assert.NoError(t, err)
70+
err = os.Mkdir(destDir, 0755)
71+
assert.NoError(t, err)
72+
73+
err = moveFile(src, destDir)
74+
assert.Error(t, err)
75+
assert.True(t, strings.Contains(err.Error(), "is a directory"))
76+
}
77+
78+
func TestMoveFile_SrcDoesNotExist(t *testing.T) {
79+
dir := t.TempDir()
80+
src := filepath.Join(dir, "no_such_file.txt")
81+
dest := filepath.Join(dir, "dest.txt")
82+
83+
err := moveFile(src, dest)
84+
assert.Error(t, err)
85+
assert.True(t, os.IsNotExist(err))
86+
}
87+
88+
func TestMoveFile_DestNoWritePermission(t *testing.T) {
89+
dir := t.TempDir()
90+
src := filepath.Join(dir, "src.txt")
91+
noPermDir := filepath.Join(dir, "no_perm")
92+
dest := filepath.Join(noPermDir, "dest.txt")
93+
94+
err := os.WriteFile(src, []byte("data"), 0644)
95+
assert.NoError(t, err)
96+
err = os.Mkdir(noPermDir, 0500)
97+
assert.NoError(t, err)
98+
99+
// Remove write permission from directory
100+
defer os.Chmod(noPermDir, 0755) // restore for cleanup
101+
102+
err = moveFile(src, dest)
103+
assert.Error(t, err)
104+
}

0 commit comments

Comments
 (0)