Shell: Add file system browser to retrieve arbitrary files

This commit is contained in:
Sebastian Serth
2022-10-04 15:21:06 +02:00
committed by Sebastian Serth
parent 60078701f5
commit 58548555a5
13 changed files with 240 additions and 38 deletions

View File

@@ -2,17 +2,17 @@ $(document).on('turbolinks:load', function () {
const ENTER_KEY_CODE = 13;
const clearOutput = function () {
$('#output').html('');
log.html('');
};
const executeCommand = function (command) {
$.ajax({
data: {
command: command,
sudo: $('#sudo').is(':checked')
sudo: sudo.is(':checked')
},
method: 'POST',
url: $('#shell').data('url')
url: Routes.execute_command_execution_environment_path(id)
}).done(handleResponse);
};
@@ -46,7 +46,7 @@ $(document).on('turbolinks:load', function () {
em.text(command);
const p = $('<p>');
p.append(em)
$('#output').append(p);
log.append(p);
};
const printOutput = function (output) {
@@ -55,22 +55,21 @@ $(document).on('turbolinks:load', function () {
const element = $('<p>');
element.addClass('text-success');
element.text(output.stdout);
$('#output').append(element);
log.append(element);
}
if (output.stderr) {
const element = $('<p>');
element.addClass('text-warning');
element.text(output.stderr);
$('#output').append(element);
log.append(element);
}
if (!output.stdout && !output.stderr) {
const element = $('<p>');
element.addClass('text-muted');
const output = $('#output');
element.text(output.data('message-no-output'));
output.append(element);
element.text(log.data('message-no-output'));
log.append(element);
}
}
};
@@ -79,26 +78,99 @@ $(document).on('turbolinks:load', function () {
const element = $('<p>');
element.addClass('text-danger');
element.text($('#shell').data('message-timeout'));
$('#output').append(element);
log.append(element);
};
const printOutOfMemory = function (output) {
const element = $('<p>');
element.addClass('text-danger');
element.text($('#shell').data('message-out-of-memory'));
$('#output').append(element);
log.append(element);
};
if ($('#shell').isPresent()) {
const command = $('#command')
command.focus();
command.on('keypress', handleKeyPress);
const retrieveFiles = function () {
let fileTree = $('#download-file-tree');
const sudo = $('#sudo');
sudo.on('change', function () {
sudo.parent().toggleClass('text-muted')
command.focus();
});
// Get current instance of the jstree if available and refresh the existing one.
// Otherwise, initialize a new one.
if (fileTree.jstree(true)) {
return fileTree.jstree('refresh');
} else {
fileTree.removeClass('my-3 justify-content-center');
fileTree.jstree({
'core': {
'data': {
'url': function (node) {
const params = {sudo: sudo.is(':checked')};
return Routes.list_files_in_execution_environment_path(id, params);
},
'data': function (node) {
return {'path': getPath(fileTree.jstree(), node)|| '/'};
}
}
}
});
fileTree.on('select_node.jstree', function (node, selected, _event) {
// We never want a node to be selected permanently, so we deselect it immediately.
selected.instance.deselect_all();
const path = getPath(selected.instance, selected.node)
const params = {sudo: sudo.is(':checked')};
const downloadPath = Routes.download_file_from_execution_environment_path(id, path, params);
// Now we download the file if allowed.
if (selected.node.original.icon.split(" ").some(function (icon) {
return ['fa-lock', 'fa-folder'].includes(icon);
})) {
$.flash.danger({
icon: ['fa-solid', 'fa-shield-halved'],
text: I18n.t('execution_environments.shell.file_tree.permission_denied')
});
} else {
window.location = downloadPath;
}
}.bind(this));
}
}
const getPath = function (jstree, node) {
if (node.id === '#') {
// Root node
return '/'
}
// We build the path to the file by concatenating the paths of all parent nodes.
let file_path = node.parents.reverse().map(function (id) {
return jstree.get_text(id);
}).filter(function (text) {
return text !== false;
}).join('/');
return `${node.parent !== '#' ? '/' : ''}${file_path}${node.original.path}`;
}
const shell = $('#shell');
if (!shell.isPresent()) {
return;
}
const command = $('#command')
command.focus();
command.on('keypress', handleKeyPress);
const id = shell.data('id');
const log = $('#output');
const sudo = $('#sudo');
sudo.on('change', function () {
sudo.parent().toggleClass('text-muted')
command.focus();
});
$('#reload-files').on('click', function () {
new bootstrap.Collapse('#collapse_files', 'show');
retrieveFiles();
});
$('#reload-now-link').on('click', retrieveFiles);
})
;

View File

@@ -40,11 +40,11 @@ input[type='file'] {
}
}
[data-bs-toggle="collapse"] .fa-solid:before {
[data-bs-toggle="collapse"] .fa-solid:first-child:before {
content: "\f139";
}
[data-bs-toggle="collapse"].collapsed .fa-solid:before {
[data-bs-toggle="collapse"].collapsed .fa-solid:first-child:before {
content: "\f13a";
}

View File

@@ -2,9 +2,10 @@
class ExecutionEnvironmentsController < ApplicationController
include CommonBehavior
include FileConversion
before_action :set_docker_images, only: %i[create edit new update]
before_action :set_execution_environment, only: MEMBER_ACTIONS + %i[execute_command shell statistics sync_to_runner_management]
before_action :set_execution_environment, only: MEMBER_ACTIONS + %i[execute_command shell list_files statistics sync_to_runner_management]
before_action :set_testing_framework_adapters, only: %i[create edit new update]
def authorize!
@@ -30,11 +31,24 @@ class ExecutionEnvironmentsController < ApplicationController
def execute_command
runner = Runner.for(current_user, @execution_environment)
sudo = ActiveModel::Type::Boolean.new.cast(params[:sudo])
output = runner.execute_command(params[:command], privileged_execution: sudo, raise_exception: false)
@privileged_execution = ActiveModel::Type::Boolean.new.cast(params[:sudo]) || @execution_environment.privileged_execution
output = runner.execute_command(params[:command], privileged_execution: @privileged_execution, raise_exception: false)
render json: output.except(:messages)
end
def list_files
runner = Runner.for(current_user, @execution_environment)
@privileged_execution = ActiveModel::Type::Boolean.new.cast(params[:sudo]) || @execution_environment.privileged_execution
begin
files = runner.retrieve_files(path: params[:path], recursive: false, privileged_execution: @privileged_execution)
downloadable_files, additional_directories = convert_files_json_to_files files
js_tree = FileTree.new(downloadable_files, additional_directories, force_closed: true).to_js_tree
render json: js_tree[:core][:data]
rescue Runner::Error::WorkspaceError
render json: []
end
end
def working_time_query
"
SELECT exercise_id, avg(working_time) as average_time, stddev_samp(extract('epoch' from working_time)) * interval '1 second' as stddev_time
@@ -225,4 +239,14 @@ class ExecutionEnvironmentsController < ApplicationController
redirect_to ExecutionEnvironment, alert: t('execution_environments.index.synchronize_all.failure')
end
end
def augment_files_for_download(files)
files.map do |file|
# Downloadable files get an indicator whether we performed a privileged execution.
# The download path is added dynamically in the frontend.
file.privileged_execution = @privileged_execution
file
end
end
private :augment_files_for_download
end

View File

@@ -21,12 +21,21 @@ class LiveStreamsController < ApplicationController
send_runner_file(runner, desired_file, fallback_location)
end
def download_arbitrary_file
@execution_environment = authorize ExecutionEnvironment.find(params[:id])
desired_file = params[:filename].to_s
runner = Runner.for(current_user, @execution_environment)
fallback_location = shell_execution_environment_path(@execution_environment)
privileged = params[:sudo] || @execution_environment.privileged_execution?
send_runner_file(runner, desired_file, fallback_location, privileged: privileged)
end
private
def send_runner_file(runner, desired_file, redirect_fallback = root_path)
def send_runner_file(runner, desired_file, redirect_fallback = root_path, privileged: false)
filename = File.basename(desired_file)
send_stream(filename: filename, disposition: 'attachment') do |stream|
runner.download_file desired_file do |chunk, overall_size, content_type|
runner.download_file desired_file, privileged_execution: privileged do |chunk, overall_size, content_type|
unless response.committed?
# Disable Rack::ETag, which would otherwise cause the response to be cached
# See https://github.com/rack/rack/issues/1619#issuecomment-848460528

View File

@@ -11,6 +11,8 @@ module CodeOcean
ROLES = %w[regular_file main_file reference_implementation executable_file teacher_defined_test user_defined_file
user_defined_test teacher_defined_linter].freeze
TEACHER_DEFINED_ROLES = ROLES - %w[user_defined_file]
OWNER_READ_PERMISSION = 0o400
OTHER_READ_PERMISSION = 0o004
after_initialize :set_default_values
before_validation :clear_weight, unless: :teacher_defined_assessment?
@@ -19,7 +21,8 @@ module CodeOcean
attr_writer :size
# These attributes are mainly used when retrieving files from a runner
attr_accessor :download_path
attr_accessor :download_path, :owner, :group, :privileged_execution
attr_reader :permissions
belongs_to :context, polymorphic: true
belongs_to :file, class_name: 'CodeOcean::File', optional: true # This is only required for submissions and is validated below
@@ -152,5 +155,31 @@ module CodeOcean
content.size
end
end
def permissions=(permission_string)
# We iterate through the permission string (e.g., `rwxrw-r--`) as received through Linux
# For each character in the string, we check for a corresponding permission (which is available if the character is not `-`)
# Then, we use a bit shift to move a `1` to the position of the given permission.
# First, it is moved within a group (e.g., `r` in `rwx` is moved twice to the left, `w` once, `x` not at all)
# Second, the bit is moved in accordance with the group (e.g., the `owner` is moved twice, the `group` once, the `other` group not at all)
# Finally, a sum is created, which technically could be an OR operation as well.
@permissions = permission_string.chars.map.with_index do |permission, index|
next 0 if permission == '-' # No permission
bit = 0b1 << ((2 - index) % 3) # Align bit in respective group
bit << ((2 - (index / 3)) * 3) # Align bit in bytes (for the group)
end.sum
end
def missing_read_permissions?
return false if permissions.blank?
# We use a bitwise AND with the permission bits and compare that to zero
if privileged_execution.present?
(permissions & OWNER_READ_PERMISSION).zero?
else
(permissions & OTHER_READ_PERMISSION).zero?
end
end
end
end

View File

@@ -1,7 +1,8 @@
# frozen_string_literal: true
class ExecutionEnvironmentPolicy < AdminOnlyPolicy
%i[execute_command? shell? statistics? show? sync_to_runner_management?].each do |action|
# download_arbitrary_file? is used in the live_streams_controller.rb
%i[execute_command? shell? list_files? statistics? show? sync_to_runner_management? download_arbitrary_file?].each do |action|
define_method(action) { admin? || author? }
end

View File

@@ -1,11 +1,29 @@
h1 = @execution_environment
#shell data-message-timeout=t('exercises.editor.timeout', permitted_execution_time: @execution_environment.permitted_execution_time) data-message-out-of-memory=t('exercises.editor.out_of_memory', memory_limit: @execution_environment.memory_limit) data-url=execute_command_execution_environment_path(@execution_environment)
#shell data-message-timeout=t('exercises.editor.timeout', permitted_execution_time: @execution_environment.permitted_execution_time) data-message-out-of-memory=t('exercises.editor.out_of_memory', memory_limit: @execution_environment.memory_limit) data-id=@execution_environment.id
label.form-label for='command' = t('execution_environments.shell.command')
.input-group.mb-3
.input-group-text.form-switch.ps-5.text-muted
input#sudo.form-check-input.mt-0 type='checkbox'
label.ms-2 for='sudo' = 'sudo'
input#command.form-control type='text'
.card.mb-3
.card-header#download-files role="tab"
a.file-heading.collapsed.d-flex.justify-content-between.align-items-center data-bs-toggle="collapse" href="#collapse_files" aria-expanded="false"
div.clearfix role="button"
i.fa-solid aria-hidden="true"
span = t('execution_environments.shell.file_tree.headline')
div
= render('exercises/editor_button', classes: 'btn-default btn-sm', data: {:'data-bs-toggle' => 'tooltip', :'data-url' => list_files_in_execution_environment_path(@execution_environment)}, icon: 'fa-solid fa-arrows-rotate', id: 'reload-files', label: t('execution_environments.shell.file_tree.reload'), title: t('execution_environments.shell.file_tree.reload_tooltip'))
.card-collapse.collapse id="collapse_files" role="tabpanel"
.card-body.pt-0.pe-0.ps-1.pb-1
#download-file-tree.justify-content-center.d-flex.my-3
span.mx-1 = t('execution_environments.shell.file_tree.empty')
button#reload-now-link.btn.btn-link.p-0.m-0.border-0 = t('execution_environments.shell.file_tree.list_now')
.card-footer.justify-content-center.align-items-center.d-flex.text-muted
i.fa-solid.fa-info
span.ms-2 = t('execution_environments.shell.file_tree.root_notice')
pre#output data-message-no-output=t('exercises.implement.no_output', timestamp: l(Time.now, format: :short))
p = t('exercises.implement.no_output_yet')