From 00f5b5ec98654d2aceb5a2dbee27380ee0192dee Mon Sep 17 00:00:00 2001 From: crschnick Date: Tue, 16 Sep 2025 07:42:58 +0000 Subject: [PATCH] Add ability to read files via sudo --- .../impl/ApplyFileEditActionProvider.java | 11 +- .../app/browser/file/BrowserFileInput.java | 147 ++++++++++++ .../app/browser/file/BrowserFileOpener.java | 218 +----------------- .../app/browser/file/BrowserFileOutput.java | 154 +++++++++++++ .../java/io/xpipe/app/util/FileBridge.java | 23 +- .../java/io/xpipe/app/util/FileOpener.java | 5 +- lang/strings/translations_en.properties | 2 + 7 files changed, 334 insertions(+), 226 deletions(-) create mode 100644 app/src/main/java/io/xpipe/app/browser/file/BrowserFileInput.java diff --git a/app/src/main/java/io/xpipe/app/browser/action/impl/ApplyFileEditActionProvider.java b/app/src/main/java/io/xpipe/app/browser/action/impl/ApplyFileEditActionProvider.java index ca079cf42..158931e37 100644 --- a/app/src/main/java/io/xpipe/app/browser/action/impl/ApplyFileEditActionProvider.java +++ b/app/src/main/java/io/xpipe/app/browser/action/impl/ApplyFileEditActionProvider.java @@ -2,6 +2,7 @@ package io.xpipe.app.browser.action.impl; import io.xpipe.app.action.AbstractAction; import io.xpipe.app.action.ActionProvider; +import io.xpipe.app.browser.file.BrowserFileInput; import io.xpipe.app.browser.file.BrowserFileOutput; import io.xpipe.app.storage.DataStorage; @@ -28,7 +29,7 @@ public class ApplyFileEditActionProvider implements ActionProvider { String target; @NonNull - InputStream input; + BrowserFileInput input; @NonNull BrowserFileOutput output; @@ -37,9 +38,13 @@ public class ApplyFileEditActionProvider implements ActionProvider { public void executeImpl() throws Exception { output.beforeTransfer(); try (var out = output.open()) { - input.transferTo(out); + input.open().transferTo(out); + } + try { + output.onFinish(); + } finally { + input.onFinish(); } - output.onFinish(); } @Override diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileInput.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileInput.java new file mode 100644 index 000000000..1b9a14077 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileInput.java @@ -0,0 +1,147 @@ +package io.xpipe.app.browser.file; + +import io.xpipe.app.core.window.AppDialog; +import io.xpipe.app.ext.ConnectionFileSystem; +import io.xpipe.app.ext.FileEntry; +import io.xpipe.app.ext.FileInfo; +import io.xpipe.app.issue.ErrorEventFactory; +import io.xpipe.app.process.CommandBuilder; +import io.xpipe.app.process.ElevationFunction; +import io.xpipe.app.process.ProcessOutputException; +import io.xpipe.app.storage.DataStoreEntry; +import io.xpipe.core.FilePath; +import io.xpipe.core.OsType; + +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; +import java.util.Optional; + +public interface BrowserFileInput { + + static BrowserFileInput openFileInput(BrowserFileSystemTabModel model, FileEntry file) + throws Exception { + if (model.isClosed()) { + return BrowserFileInput.none(); + } + + var defOutput = createFileInputImpl(model, file, false); + if (model.getFileSystem().getShell().isEmpty()) { + return defOutput; + } + + var sc = model.getFileSystem().getShell().orElseThrow(); + var requiresSudo = + sc.getOsType() != OsType.WINDOWS && requiresSudo(model, (FileInfo.Unix) file.getInfo(), file.getPath()); + + if (!requiresSudo) { + return defOutput; + } + + var elevate = AppDialog.confirm("fileReadSudo"); + if (!elevate) { + return defOutput; + } + + var rootOutput = createFileInputImpl(model, file, true); + return rootOutput; + } + + private static boolean requiresSudo(BrowserFileSystemTabModel model, FileInfo.Unix info, FilePath filePath) + throws Exception { + if (model.getFileSystem().getShell().isEmpty() || model.getCache() == null) { + return false; + } + + if (model.getCache().isRoot()) { + return false; + } + + if (info != null) { + var otherWrite = info.getPermissions().charAt(6) == 'r'; + if (otherWrite) { + return false; + } + + var userOwned = info.getUid() != null + && model.getCache().getUidForUser(model.getCache().getUsername()) == info.getUid() + || info.getUser() != null && model.getCache().getUsername().equals(info.getUser()); + var userWrite = info.getPermissions().charAt(0) == 'r'; + if (userOwned && userWrite) { + return false; + } + } + + var test = model.getFileSystem() + .getShell() + .orElseThrow() + .command(CommandBuilder.of().add("test", "-r").addFile(filePath)) + .executeAndCheck(); + return !test; + } + + private static BrowserFileInput createFileInputImpl( + BrowserFileSystemTabModel model, FileEntry file, boolean elevate) throws Exception { + var shell = model.getFileSystem().getShell(); + var sc = shell.isEmpty() + ? null + : elevate + ? shell.orElseThrow() + .identicalDialectSubShell() + .elevated(ElevationFunction.elevated(null)) + .start() + : model.getFileSystem().getShell().orElseThrow().start(); + var fs = elevate ? new ConnectionFileSystem(sc) : model.getFileSystem(); + var output = new BrowserFileInput() { + + @Override + public InputStream open() throws Exception { + try { + return fs.openInput(file.getPath()); + } catch (Exception ex) { + if (elevate) { + fs.close(); + } + throw ex; + } + } + + @Override + public void onFinish() throws Exception { + if (elevate) { + fs.close(); + } + } + }; + return output; + } + + static BrowserFileInput none() { + return new BrowserFileInput() { + + @Override + public InputStream open() { + return null; + } + + @Override + public void onFinish() throws Exception {} + }; + } + + static BrowserFileInput of(InputStream in) { + return new BrowserFileInput() { + @Override + public InputStream open() throws Exception { + return in; + } + + @Override + public void onFinish() throws Exception {} + }; + } + + InputStream open() throws Exception; + + void onFinish() throws Exception; +} diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileOpener.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileOpener.java index 150bef59a..29e155dd9 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileOpener.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileOpener.java @@ -26,148 +26,6 @@ import java.util.Optional; public class BrowserFileOpener { - private static BrowserFileOutput openFileOutput(BrowserFileSystemTabModel model, FileEntry file, long totalBytes) - throws Exception { - if (model.isClosed()) { - return BrowserFileOutput.none(); - } - - if (totalBytes == 0) { - var existingSize = model.getFileSystem().getFileSize(file.getPath()); - if (existingSize != 0) { - var blank = AppDialog.confirm( - "fileWriteBlankTitle", AppI18n.observable("fileWriteBlankContent", file.getPath())); - if (!blank) { - return BrowserFileOutput.none(); - } - } - } - - var defOutput = createFileOutput(model, file, totalBytes, false); - if (model.getFileSystem().getShell().isEmpty()) { - return defOutput; - } - - var sc = model.getFileSystem().getShell().orElseThrow(); - var requiresSudo = - sc.getOsType() != OsType.WINDOWS && requiresSudo(model, (FileInfo.Unix) file.getInfo(), file.getPath()); - - if (!requiresSudo) { - return defOutput; - } - - var elevate = AppDialog.confirm("fileWriteSudo"); - if (!elevate) { - return defOutput; - } - - var rootOutput = createFileOutput(model, file, totalBytes, true); - return rootOutput; - } - - private static boolean requiresSudo(BrowserFileSystemTabModel model, FileInfo.Unix info, FilePath filePath) - throws Exception { - if (model.getFileSystem().getShell().isEmpty() || model.getCache() == null) { - return false; - } - - if (model.getCache().isRoot()) { - return false; - } - - if (info != null) { - var otherWrite = info.getPermissions().charAt(7) == 'w'; - if (otherWrite) { - return false; - } - - var userOwned = info.getUid() != null - && model.getCache().getUidForUser(model.getCache().getUsername()) == info.getUid() - || info.getUser() != null && model.getCache().getUsername().equals(info.getUser()); - var userWrite = info.getPermissions().charAt(1) == 'w'; - if (userOwned && userWrite) { - return false; - } - } - - var test = model.getFileSystem() - .getShell() - .orElseThrow() - .command(CommandBuilder.of().add("test", "-w").addFile(filePath)) - .executeAndCheck(); - return !test; - } - - private static BrowserFileOutput createFileOutput( - BrowserFileSystemTabModel model, FileEntry file, long totalBytes, boolean elevate) throws Exception { - var shell = model.getFileSystem().getShell(); - var sc = shell.isEmpty() - ? null - : elevate - ? shell.orElseThrow() - .identicalDialectSubShell() - .elevated(ElevationFunction.elevated(null)) - .start() - : model.getFileSystem().getShell().orElseThrow().start(); - var fs = elevate ? new ConnectionFileSystem(sc) : model.getFileSystem(); - var checkSudoersFile = shell.isPresent() && file.getPath().startsWith("/etc/sudo"); - var output = new BrowserFileOutput() { - - @Override - public Optional target() { - return Optional.of(model.getEntry().get()); - } - - @Override - public boolean hasOutput() { - return true; - } - - @Override - public OutputStream open() throws Exception { - try { - return fs.openOutput(file.getPath(), totalBytes); - } catch (Exception ex) { - if (elevate) { - fs.close(); - } - throw ex; - } - } - - @Override - public void beforeTransfer() throws Exception { - if (checkSudoersFile) { - fs.copy(file.getPath(), sc.getSystemTemporaryDirectory().join(file.getName())); - } - } - - @Override - public void onFinish() throws Exception { - if (checkSudoersFile) { - if (sc.view().findProgram("visudo").isPresent()) { - try { - sc.command(CommandBuilder.of() - .add("visudo", "-c", "-f") - .addFile(file.getPath())) - .execute(); - } catch (ProcessOutputException ex) { - ErrorEventFactory.fromThrowable(ex).expected().handle(); - fs.copy(sc.getSystemTemporaryDirectory().join(file.getName()), file.getPath()); - } - } - } - - if (elevate) { - fs.close(); - } - - model.refreshFileEntriesSync(List.of(file)); - } - }; - return output; - } - @SneakyThrows private static int calculateKey(BrowserFileSystemTabModel model, FileEntry entry) { // Use different key for empty / non-empty files to prevent any issues from blanked files when transfer fails @@ -189,39 +47,8 @@ public class BrowserFileOpener { file.getFileName(), key, new BooleanScope(model.getBusy()).exclusive(), - () -> { - return entry.getFileSystem().openInput(file); - }, - (size) -> { - if (model.isClosed()) { - return BrowserFileOutput.none(); - } - - return new BrowserFileOutput() { - @Override - public Optional target() { - return Optional.of(model.getEntry().get()); - } - - @Override - public boolean hasOutput() { - return true; - } - - @Override - public OutputStream open() throws Exception { - return entry.getFileSystem().openOutput(file, size); - } - - @Override - public void beforeTransfer() {} - - @Override - public void onFinish() { - model.refreshFileEntriesSync(List.of(entry)); - } - }; - }, + () -> BrowserFileInput.openFileInput(model, entry), + (size) -> BrowserFileOutput.openFileOutput(model, entry, size), s -> FileOpener.openWithAnyApplication(s)); } @@ -239,39 +66,8 @@ public class BrowserFileOpener { file.getFileName(), key, new BooleanScope(model.getBusy()).exclusive(), - () -> { - return entry.getFileSystem().openInput(file); - }, - (size) -> { - if (model.isClosed()) { - return BrowserFileOutput.none(); - } - - return new BrowserFileOutput() { - @Override - public Optional target() { - return Optional.of(model.getEntry().get()); - } - - @Override - public boolean hasOutput() { - return true; - } - - @Override - public OutputStream open() throws Exception { - return entry.getFileSystem().openOutput(file, size); - } - - @Override - public void beforeTransfer() {} - - @Override - public void onFinish() { - model.refreshFileEntriesSync(List.of(entry)); - } - }; - }, + () -> BrowserFileInput.openFileInput(model, entry), + (size) -> BrowserFileOutput.openFileOutput(model, entry, size), s -> FileOpener.openInDefaultApplication(s)); } @@ -294,11 +90,9 @@ public class BrowserFileOpener { file.getFileName(), key, new BooleanScope(model.getBusy()).exclusive(), - () -> { - return entry.getFileSystem().openInput(file); - }, + () -> BrowserFileInput.openFileInput(model, entry), (size) -> { - return openFileOutput(model, entry, size); + return BrowserFileOutput.openFileOutput(model, entry, size); }, FileOpener::openInTextEditor); } diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileOutput.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileOutput.java index 0202edd19..023ba8044 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileOutput.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileOutput.java @@ -1,12 +1,166 @@ package io.xpipe.app.browser.file; +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.core.window.AppDialog; +import io.xpipe.app.ext.ConnectionFileSystem; +import io.xpipe.app.ext.FileEntry; +import io.xpipe.app.ext.FileInfo; +import io.xpipe.app.issue.ErrorEventFactory; +import io.xpipe.app.process.CommandBuilder; +import io.xpipe.app.process.ElevationFunction; +import io.xpipe.app.process.ProcessOutputException; import io.xpipe.app.storage.DataStoreEntry; +import io.xpipe.core.FilePath; +import io.xpipe.core.OsType; import java.io.OutputStream; +import java.util.List; import java.util.Optional; public interface BrowserFileOutput { + static BrowserFileOutput openFileOutput(BrowserFileSystemTabModel model, FileEntry file, long totalBytes) + throws Exception { + if (model.isClosed()) { + return BrowserFileOutput.none(); + } + + if (totalBytes == 0) { + var existingSize = model.getFileSystem().getFileSize(file.getPath()); + if (existingSize != 0) { + var blank = AppDialog.confirm( + "fileWriteBlankTitle", AppI18n.observable("fileWriteBlankContent", file.getPath())); + if (!blank) { + return BrowserFileOutput.none(); + } + } + } + + var defOutput = createFileOutputImpl(model, file, totalBytes, false); + if (model.getFileSystem().getShell().isEmpty()) { + return defOutput; + } + + var sc = model.getFileSystem().getShell().orElseThrow(); + var requiresSudo = + sc.getOsType() != OsType.WINDOWS && requiresSudo(model, (FileInfo.Unix) file.getInfo(), file.getPath()); + + if (!requiresSudo) { + return defOutput; + } + + var elevate = AppDialog.confirm("fileWriteSudo"); + if (!elevate) { + return defOutput; + } + + var rootOutput = createFileOutputImpl(model, file, totalBytes, true); + return rootOutput; + } + + private static boolean requiresSudo(BrowserFileSystemTabModel model, FileInfo.Unix info, FilePath filePath) + throws Exception { + if (model.getFileSystem().getShell().isEmpty() || model.getCache() == null) { + return false; + } + + if (model.getCache().isRoot()) { + return false; + } + + if (info != null) { + var otherWrite = info.getPermissions().charAt(7) == 'w'; + if (otherWrite) { + return false; + } + + var userOwned = info.getUid() != null + && model.getCache().getUidForUser(model.getCache().getUsername()) == info.getUid() + || info.getUser() != null && model.getCache().getUsername().equals(info.getUser()); + var userWrite = info.getPermissions().charAt(1) == 'w'; + if (userOwned && userWrite) { + return false; + } + } + + var test = model.getFileSystem() + .getShell() + .orElseThrow() + .command(CommandBuilder.of().add("test", "-w").addFile(filePath)) + .executeAndCheck(); + return !test; + } + + private static BrowserFileOutput createFileOutputImpl( + BrowserFileSystemTabModel model, FileEntry file, long totalBytes, boolean elevate) throws Exception { + var shell = model.getFileSystem().getShell(); + var sc = shell.isEmpty() + ? null + : elevate + ? shell.orElseThrow() + .identicalDialectSubShell() + .elevated(ElevationFunction.elevated(null)) + .start() + : model.getFileSystem().getShell().orElseThrow().start(); + var fs = elevate ? new ConnectionFileSystem(sc) : model.getFileSystem(); + var checkSudoersFile = shell.isPresent() && file.getPath().startsWith("/etc/sudo"); + var output = new BrowserFileOutput() { + + @Override + public Optional target() { + return Optional.of(model.getEntry().get()); + } + + @Override + public boolean hasOutput() { + return true; + } + + @Override + public OutputStream open() throws Exception { + try { + return fs.openOutput(file.getPath(), totalBytes); + } catch (Exception ex) { + if (elevate) { + fs.close(); + } + throw ex; + } + } + + @Override + public void beforeTransfer() throws Exception { + if (checkSudoersFile) { + fs.copy(file.getPath(), sc.getSystemTemporaryDirectory().join(file.getName())); + } + } + + @Override + public void onFinish() throws Exception { + if (checkSudoersFile) { + if (sc.view().findProgram("visudo").isPresent()) { + try { + sc.command(CommandBuilder.of() + .add("visudo", "-c", "-f") + .addFile(file.getPath())) + .execute(); + } catch (ProcessOutputException ex) { + ErrorEventFactory.fromThrowable(ex).expected().handle(); + fs.copy(sc.getSystemTemporaryDirectory().join(file.getName()), file.getPath()); + } + } + } + + if (elevate) { + fs.close(); + } + + model.refreshFileEntriesSync(List.of(file)); + } + }; + return output; + } + static BrowserFileOutput none() { return new BrowserFileOutput() { diff --git a/app/src/main/java/io/xpipe/app/util/FileBridge.java b/app/src/main/java/io/xpipe/app/util/FileBridge.java index a428ba480..dd5d0aaaa 100644 --- a/app/src/main/java/io/xpipe/app/util/FileBridge.java +++ b/app/src/main/java/io/xpipe/app/util/FileBridge.java @@ -1,6 +1,7 @@ package io.xpipe.app.util; import io.xpipe.app.browser.action.impl.ApplyFileEditActionProvider; +import io.xpipe.app.browser.file.BrowserFileInput; import io.xpipe.app.browser.file.BrowserFileOutput; import io.xpipe.app.core.AppFileWatcher; import io.xpipe.app.issue.ErrorEventFactory; @@ -145,16 +146,18 @@ public class FileBridge { String keyName, Object key, BooleanScope scope, - FailableSupplier input, - FailableFunction output, + FailableSupplier inputSupplier, + FailableFunction outputSupplier, Consumer consumer) { var ext = getForKey(key); if (ext.isPresent()) { var existingFile = ext.get().file; try { - try (var out = Files.newOutputStream(existingFile); - var in = input.get()) { + var input = inputSupplier.get(); + try (var out = Files.newOutputStream(existingFile); var in = input.open()) { in.transferTo(out); + } finally { + input.onFinish(); } } catch (Exception ex) { ErrorEventFactory.fromThrowable(ex).handle(); @@ -169,9 +172,11 @@ public class FileBridge { .resolve(OsFileSystem.ofLocal().makeFileSystemCompatible(keyName)); try { FileUtils.forceMkdirParent(file.toFile()); - try (var out = Files.newOutputStream(file); - var in = input.get()) { + var input = inputSupplier.get(); + try (var out = Files.newOutputStream(file); var in = input.open()) { in.transferTo(out); + } finally { + input.onFinish(); } } catch (Exception ex) { ErrorEventFactory.fromThrowable(ex).handle(); @@ -179,16 +184,16 @@ public class FileBridge { } var entry = new Entry(file, key, keyName, scope, (in, size) -> { - if (output != null) { + if (outputSupplier != null) { var effectiveScope = scope != null ? scope : BooleanScope.noop(); try (var ignored = effectiveScope.start()) { - var outSupplier = output.apply(size); + var outSupplier = outputSupplier.apply(size); if (!outSupplier.hasOutput()) { return; } var action = ApplyFileEditActionProvider.Action.builder() - .input(in) + .input(BrowserFileInput.of(in)) .output(outSupplier) .target(file.getFileName().toString()) .build(); diff --git a/app/src/main/java/io/xpipe/app/util/FileOpener.java b/app/src/main/java/io/xpipe/app/util/FileOpener.java index 1e3be4bcb..f624fc7c2 100644 --- a/app/src/main/java/io/xpipe/app/util/FileOpener.java +++ b/app/src/main/java/io/xpipe/app/util/FileOpener.java @@ -1,5 +1,6 @@ package io.xpipe.app.util; +import io.xpipe.app.browser.file.BrowserFileInput; import io.xpipe.app.browser.file.BrowserFileOutput; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.prefs.AppPrefs; @@ -105,7 +106,7 @@ public class FileOpener { id.toString(), id, null, - () -> new ByteArrayInputStream(s.getBytes(StandardCharsets.UTF_8)), + () -> BrowserFileInput.of(new ByteArrayInputStream(s.getBytes(StandardCharsets.UTF_8))), null, v -> openInTextEditor(v)); } @@ -121,7 +122,7 @@ public class FileOpener { keyName, key, null, - () -> new ByteArrayInputStream(s.getBytes(StandardCharsets.UTF_8)), + () -> BrowserFileInput.of(new ByteArrayInputStream(s.getBytes(StandardCharsets.UTF_8))), (size) -> { return new BrowserFileOutput() { @Override diff --git a/lang/strings/translations_en.properties b/lang/strings/translations_en.properties index 77d1f383e..83117368e 100644 --- a/lang/strings/translations_en.properties +++ b/lang/strings/translations_en.properties @@ -1635,3 +1635,5 @@ rdpSmartSizing=Enable smart sizing rdpSmartSizingDescription=When enabled, mstsc will scale down the desktop size if the window is too small to display it in its full resolution. The aspect ratio of the desktop is preserved when scaled down. disableStartOnInit=Disable automatic startup enableStartOnInit=Enable automatic startup +fileReadSudoTitle=Sudo file read +fileReadSudoContent=The file you are trying to read requires root privileges. Do you want to read this file with sudo?