Add ability to read files via sudo

This commit is contained in:
crschnick
2025-09-16 07:42:58 +00:00
parent 6a1bd89a15
commit 00f5b5ec98
7 changed files with 334 additions and 226 deletions
@@ -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
@@ -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;
}
@@ -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<DataStoreEntry> 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<DataStoreEntry> 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<DataStoreEntry> 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);
}
@@ -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<DataStoreEntry> 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() {
@@ -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<InputStream> input,
FailableFunction<Long, BrowserFileOutput, Exception> output,
FailableSupplier<BrowserFileInput> inputSupplier,
FailableFunction<Long, BrowserFileOutput, Exception> outputSupplier,
Consumer<String> 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();
@@ -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
+2
View File
@@ -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?