diff --git a/bases/rsptx/book_server_api/routers/rslogging.py b/bases/rsptx/book_server_api/routers/rslogging.py index 3c89db83e..17629eff2 100644 --- a/bases/rsptx/book_server_api/routers/rslogging.py +++ b/bases/rsptx/book_server_api/routers/rslogging.py @@ -665,13 +665,16 @@ async def get_source_code( if db_result: # Found data, unpack desired fields file_contents = db_result.main_code + is_binary = db_result.is_binary if db_result.filename: filename = db_result.filename else: file_contents = None + is_binary = None response_bundle = { "filename": filename, "file_contents": file_contents, + "is_binary": is_binary, } return make_json_response(detail=response_bundle) diff --git a/bases/rsptx/interactives/runestone/activecode/js/livecode.js b/bases/rsptx/interactives/runestone/activecode/js/livecode.js index d61fa6652..9872e33b2 100644 --- a/bases/rsptx/interactives/runestone/activecode/js/livecode.js +++ b/bases/rsptx/interactives/runestone/activecode/js/livecode.js @@ -277,7 +277,7 @@ export default class LiveCode extends ActiveCode { var files = []; for (let f of allFilesRaw) { // Need to determine content and filename for each datafile - let fileName, content; + let fileName, content, isBinary = false; // Check on page to see if we have the datafile. // Datafiles are looked up via data-filename attribute while additional_files are looked up via id let fileElement; @@ -310,6 +310,9 @@ export default class LiveCode extends ActiveCode { // If the file came from an item with a data-filename attribute, use that as the filename // otherwise this must be an RST item with filename as the id fileName = fileElement.dataset.filename || f.filename; + isBinary = + fileElement.dataset.isbinary === "true" || + fileElement.dataset.isBinary === "true"; } else { // check to see if file is in db let result = null; @@ -356,10 +359,19 @@ export default class LiveCode extends ActiveCode { // favor student code if it exists content = studentCode || result.file_contents; fileName = result.filename; + // binary files (e.g. .jar) are stored base64; the is_binary + // flag tells us to hand the content to the server verbatim + isBinary = result.is_binary === true; } } - if (fileName) { + if (fileName && isBinary) { + files.push({ + name: fileName, + content: content, + isBinary: true, + }); + } else if (fileName) { let fileExtension = fileName.substring( fileName.lastIndexOf(".") + 1, ); @@ -425,15 +437,82 @@ export default class LiveCode extends ActiveCode { paramobj.compileargs = [sourcefilename]; } } + // "compile-also" marks additional files as part of the build. What + // that means is language-specific and depends on whether the file is + // text or binary. The list carries filenames (PreTeXt emits + // @filename), so match them against the collected files to learn each + // one's type. These files are also always part of the file_list, so + // they are delivered to the working directory regardless. + let buildFileNames = []; if (this.compileAlso) { - // a comma separated list of files that also need to be compiled - // they also all should be part of additional_files - // we will stick them onto the end of the compilerargs - // so that jobe builds them into the string sent to the compiler - // e.g. g++ [other_compile_args] [compileAlso] sourcefile -o executable + buildFileNames = this.compileAlso + .split(",") + .map((n) => n.trim()) + .filter((n) => n !== ""); + } + let classpathNames = []; + let linkFileNames = []; + for (let name of buildFileNames) { + let f = files.find((file) => file.name === name); + let extension = name.substring(name.lastIndexOf(".") + 1); + if (f && f.isBinary) { + if ( + (this.language === "java" || + this.language === "kotlin") && + ["jar", "zip"].indexOf(extension) > -1 + ) { + // A compiled archive (.jar/.zip) must be on the classpath + // for the compiler and runtime to see the classes it holds. + classpathNames.push(name); + } else if ( + (this.language === "c" || this.language === "cpp") && + ["o", "a"].indexOf(extension) > -1 + ) { + // Binary link inputs (.o/.a) must come AFTER the source + // file on the command line, so they belong in linkargs, + // not compileargs. (.so is unsupported: the server cannot + // arrange for the runtime linker to find it.) + linkFileNames.push(name); + } + // Other languages or file types: nothing to wire; the file is + // in the working directory for the program to read. + } else { + // Text source: compile it together with the main program. + paramobj.compileargs = paramobj.compileargs || []; + if (paramobj.compileargs.indexOf(name) === -1) { + paramobj.compileargs.push(name); + } + } + } + if (classpathNames.length > 0) { + let classpath = + "." + classpathNames.map((n) => ":" + n).join(""); + // Jobe replaces interpreterargs wholesale rather than merging, + // and the client cannot discover Jobe's defaults, so reproduce + // the -X flags Jobe would otherwise supply (mirrors java_task.php). + // The -cp must precede the main class, so it rides in + // interpreterargs rather than runargs. + let interpreterArgs = ["-Xrs", "-Xss8m", "-Xmx200m"]; + if (paramobj.interpreterargs) { + interpreterArgs = paramobj.interpreterargs; + } + interpreterArgs = interpreterArgs.concat(["-cp", classpath]); + paramobj.interpreterargs = interpreterArgs; + // The default compileargs for Java/Kotlin is empty, so appending + // is safe. paramobj.compileargs = paramobj.compileargs || []; - let compileList = this.compileAlso.split(","); - paramobj.compileargs = paramobj.compileargs.concat(compileList); + paramobj.compileargs = paramobj.compileargs.concat([ + "-cp", + classpath, + ]); + } + if (linkFileNames.length > 0) { + paramobj.linkargs = paramobj.linkargs || []; + for (let name of linkFileNames) { + if (paramobj.linkargs.indexOf(name) === -1) { + paramobj.linkargs.push(name); + } + } } let runspec = { language_id: this.language, @@ -798,7 +877,9 @@ export default class LiveCode extends ActiveCode { // File types being uploaded that come in already in base64 format var extensions = ["jar", "zip", "png", "jpg", "jpeg"]; var contentsb64; - if (extensions.indexOf(extension) === -1) { + if (file.isBinary) { + contentsb64 = contents; + } else if (extensions.indexOf(extension) === -1) { contentsb64 = base64encode(contents); } else { contentsb64 = contents; diff --git a/bases/rsptx/interactives/runestone/activecode/test/livecode_binary.test.js b/bases/rsptx/interactives/runestone/activecode/test/livecode_binary.test.js new file mode 100644 index 000000000..b479cc8f2 --- /dev/null +++ b/bases/rsptx/interactives/runestone/activecode/test/livecode_binary.test.js @@ -0,0 +1,322 @@ +// Tests for LiveCode's handling of binary files (e.g. a compiled .jar). +// +// runSetup() assembles the list of files to push to Jobe before submitting the +// run. Binary files are carried as base64 and must be passed to the server +// verbatim -- never base64-encoded again, and never fed to +// parseJavaClasses(). These tests drive runSetup directly with the network +// and CodeMirror machinery stubbed out. +import { describe, it, expect, beforeEach } from "vitest"; +import LiveCode from "../js/livecode.js"; + +// Build a bare LiveCode instance (no DOM component, no CodeMirror), wiring in +// the small set of collaborators runSetup touches, and capturing the file +// objects checkFile is fed. +function makeRunner({ + additionalFiles = "helper-jar", + compileAlso = undefined, + pageFiles = [], +} = {}) { + const lc = Object.create(LiveCode.prototype); + lc.additional_files = additionalFiles; + lc.datafiles = undefined; + lc.language = "python3"; + lc.editor = { getValue: () => "print('hi')" }; + lc.autorun = true; + lc.historyScrubber = null; + lc.trimLockedCode = (c) => c; + lc.output = { innerHTML: "" }; + lc.sourcefile = undefined; + lc.suffix = undefined; + lc.prefix = undefined; + lc.includes = undefined; + lc.compileAlso = compileAlso; + lc.jsonHeaders = {}; + lc.div2id = {}; + lc.submitted = []; + lc.checkFile = async (file, resolve) => { + lc.submitted.push(file); + resolve(); + }; + // Place the referenced files in the (jsdom) DOM. + document.body.innerHTML = pageFiles.join("\n"); + return lc; +} + +beforeEach(() => { + document.body.innerHTML = ""; +}); + +// The DB fallback path in runSetup builds `new Request(relativeUrl)` and then +// `fetch(request)`. Browser fetch resolves relative URLs against the page +// URL; Node's undici does not, so install a Request that carries the URL and a +// fetch that responds from it. +function stubSourceCodeFetcher(baseCourse, derivedCourse, responsesByUrl) { + global.Request = class { + constructor(url, opts) { + this.url = String(url); + this.method = (opts && opts.method) || "GET"; + this.headers = (opts && opts.headers) || {}; + } + }; + global.fetch = async (input) => { + const url = typeof input === "string" ? input : input.url; + const response = responsesByUrl[url]; + if (!response) { + throw new Error(`unexpected fetch: ${url}`); + } + const detail = typeof response === "function" ? response() : response; + return { ok: true, json: () => Promise.resolve(detail) }; + }; + + const latest = (acid) => + `/ns/assessment/get_latest_code?acid=${encodeURIComponent(acid)}`; + const source = (acid) => + `/ns/logger/get_source_code?course_id=test_course&acid=${encodeURIComponent(acid)}`; + return { latest, source }; +} + +function resetFetchers() { + delete globalThis.Request; + delete globalThis.fetch; +} + +describe("binary files in runSetup", () => { + it("flags a data-isbinary page element and passes its base64 verbatim", async () => { + const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA=="; + const lc = makeRunner({ + additionalFiles: "PTXSB_2_helper-jar", + pageFiles: [ + `
${base64}`,
+ ],
+ });
+ await lc.runSetup();
+ expect(lc.submitted).toHaveLength(1);
+ expect(lc.submitted[0]).toEqual({
+ name: "helper.jar",
+ content: base64,
+ isBinary: true,
+ });
+ });
+
+ it("does not split a binary .jar with parseJavaClasses", async () => {
+ const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA==";
+ const lc = makeRunner({
+ additionalFiles: "helper-jar",
+ pageFiles: [
+ `${base64}`,
+ ],
+ });
+ lc.parseJavaClasses = (text) => {
+ throw new Error("parseJavaClasses must not run on binary data");
+ };
+ await lc.runSetup();
+ expect(lc.submitted).toHaveLength(1);
+ expect(lc.submitted[0].isBinary).toBe(true);
+ });
+
+ it("flags a DB-sourced file whose is_binary marks it binary", async () => {
+ const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA==";
+ const lc = makeRunner({ additionalFiles: "cross-page-jar", pageFiles: [] });
+ const latestUrl = `/ns/assessment/get_latest_code?acid=cross-page-jar`;
+ const sourceUrl = `/ns/logger/get_source_code?course_id=test_course&acid=cross-page-jar`;
+ stubSourceCodeFetcher("test_course", "test_course", {
+ [latestUrl]: () => ({
+ detail: { code: null },
+ }),
+ [sourceUrl]: {
+ detail: {
+ filename: "helper.jar",
+ file_contents: base64,
+ // True here means binary; text files come back false.
+ is_binary: true,
+ },
+ },
+ });
+ await lc.runSetup();
+ expect(lc.submitted).toHaveLength(1);
+ expect(lc.submitted[0]).toEqual({
+ name: "helper.jar",
+ content: base64,
+ isBinary: true,
+ });
+ resetFetchers();
+ });
+
+ it("treats a fetched file with is_binary false as plain text", async () => {
+ const lc = makeRunner({ additionalFiles: "cross-text-file", pageFiles: [] });
+ const latest = `/ns/assessment/get_latest_code?acid=cross-text-file`;
+ const source = `/ns/logger/get_source_code?course_id=test_course&acid=cross-text-file`;
+ stubSourceCodeFetcher("test_course", "test_course", {
+ [latest]: () => ({
+ detail: { code: null },
+ }),
+ [source]: {
+ detail: {
+ filename: "helper.txt",
+ file_contents: "plain text",
+ // A false is_binary means text, so the client must not
+ // hand the contents to the server as base64.
+ is_binary: false,
+ },
+ },
+ });
+ await lc.runSetup();
+ expect(lc.submitted).toHaveLength(1);
+ expect(lc.submitted[0]).toEqual({
+ name: "helper.txt",
+ content: "plain text",
+ });
+ resetFetchers();
+ });
+
+ it("adds a binary jar to the Java compile and runtime classpath", async () => {
+ const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA==";
+ const lc = makeRunner({
+ additionalFiles: "PTXSB_2_helper-jar",
+ compileAlso: "helper.jar",
+ pageFiles: [
+ `${base64}`,
+ ],
+ });
+ lc.language = "java";
+ await lc.runSetup();
+ const runspec = JSON.parse(lc.json_runspec).run_spec;
+ expect(runspec.parameters.compileargs).toEqual([
+ "-cp",
+ ".:helper.jar",
+ ]);
+ // Jobe replaces interpreterargs wholesale, so we must reproduce its
+ // Java defaults (the -X flags from java_task.php) and append the -cp.
+ expect(runspec.parameters.interpreterargs).toEqual([
+ "-Xrs",
+ "-Xss8m",
+ "-Xmx200m",
+ "-cp",
+ ".:helper.jar",
+ ]);
+ });
+
+ it("puts a binary jar on the classpath for Kotlin too", async () => {
+ const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA==";
+ const lc = makeRunner({
+ additionalFiles: "PTXSB_2_helper-jar",
+ compileAlso: "helper.jar",
+ pageFiles: [
+ `${base64}`,
+ ],
+ });
+ lc.language = "kotlin";
+ await lc.runSetup();
+ const runspec = JSON.parse(lc.json_runspec).run_spec;
+ expect(runspec.parameters.compileargs).toEqual([
+ "-cp",
+ ".:helper.jar",
+ ]);
+ expect(runspec.parameters.interpreterargs).toEqual([
+ "-Xrs",
+ "-Xss8m",
+ "-Xmx200m",
+ "-cp",
+ ".:helper.jar",
+ ]);
+ });
+
+ it("does not add a binary jar to the classpath unless it is compile-also", async () => {
+ const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA==";
+ const lc = makeRunner({
+ additionalFiles: "PTXSB_2_helper-jar",
+ pageFiles: [
+ `${base64}`,
+ ],
+ });
+ lc.language = "java";
+ await lc.runSetup();
+ const runspec = JSON.parse(lc.json_runspec).run_spec;
+ expect(runspec.parameters.compileargs).toBeUndefined();
+ expect(runspec.parameters.interpreterargs).toBeUndefined();
+ });
+
+ it("keeps author-supplied interpreterargs when adding the jar classpath", async () => {
+ const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA==";
+ const lc = makeRunner({
+ additionalFiles: "PTXSB_2_helper-jar",
+ compileAlso: "helper.jar",
+ pageFiles: [
+ `${base64}`,
+ ],
+ });
+ lc.language = "java";
+ lc.interpreterargs = '["-Xmx512m"]';
+ await lc.runSetup();
+ const runspec = JSON.parse(lc.json_runspec).run_spec;
+ expect(runspec.parameters.interpreterargs).toEqual([
+ "-Xmx512m",
+ "-cp",
+ ".:helper.jar",
+ ]);
+ });
+
+ it("does not touch the classpath for a non-archive binary file", async () => {
+ const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA==";
+ const lc = makeRunner({
+ additionalFiles: "PTXSB_2_pic-datafile",
+ compileAlso: "pic.png",
+ pageFiles: [
+ `${base64}`,
+ ],
+ });
+ lc.language = "java";
+ await lc.runSetup();
+ const runspec = JSON.parse(lc.json_runspec).run_spec;
+ expect(runspec.parameters.compileargs).toBeUndefined();
+ expect(runspec.parameters.interpreterargs).toBeUndefined();
+ });
+
+ it("puts a binary object file on the C++ linkargs", async () => {
+ const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA==";
+ const lc = makeRunner({
+ additionalFiles: "PTXSB_2_helper-o",
+ compileAlso: "helper.o",
+ pageFiles: [
+ `${base64}`,
+ ],
+ });
+ lc.language = "cpp";
+ await lc.runSetup();
+ const runspec = JSON.parse(lc.json_runspec).run_spec;
+ expect(runspec.parameters.linkargs).toEqual(["helper.o"]);
+ expect(runspec.parameters.compileargs).toBeUndefined();
+ });
+
+ it("puts a text source on the C++ compileargs, not linkargs", async () => {
+ const lc = makeRunner({
+ additionalFiles: "PTXSB_2_addcpp",
+ compileAlso: "add.cpp",
+ pageFiles: [
+ `int add(int a, int b) { return a + b; }`,
+ ],
+ });
+ lc.language = "cpp";
+ await lc.runSetup();
+ const runspec = JSON.parse(lc.json_runspec).run_spec;
+ expect(runspec.parameters.compileargs).toEqual(["add.cpp"]);
+ expect(runspec.parameters.linkargs).toBeUndefined();
+ });
+
+ it("does not wire binary compile-also files for Python", async () => {
+ const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA==";
+ const lc = makeRunner({
+ additionalFiles: "PTXSB_2_pkg-whl",
+ compileAlso: "pkg.whl",
+ pageFiles: [
+ `${base64}`,
+ ],
+ });
+ lc.language = "python3";
+ await lc.runSetup();
+ const runspec = JSON.parse(lc.json_runspec).run_spec;
+ expect(runspec.parameters.compileargs).toBeUndefined();
+ expect(runspec.parameters.linkargs).toBeUndefined();
+ expect(runspec.parameters.interpreterargs).toBeUndefined();
+ });
+});
\ No newline at end of file
diff --git a/bases/rsptx/interactives/runestone/datafile/js/datafile.js b/bases/rsptx/interactives/runestone/datafile/js/datafile.js
index 5df06e28a..71fba79b4 100644
--- a/bases/rsptx/interactives/runestone/datafile/js/datafile.js
+++ b/bases/rsptx/interactives/runestone/datafile/js/datafile.js
@@ -23,6 +23,7 @@ class DataFile extends RunestoneBase {
this.divid = orig.id;
this.dataEdit = this.parseBooleanAttribute(orig, "data-edit");
this.isImage = this.parseBooleanAttribute(orig, "data-isimage");
+ this.isBinary = this.parseBooleanAttribute(orig, "data-isbinary");
this.fileName = orig.dataset.filename || null;
this.displayClass = "block"; // Users can specify the non-edit component to be hidden--default is not hidden
if (this.parseBooleanAttribute(orig, "data-hidden")) {
@@ -60,6 +61,7 @@ class DataFile extends RunestoneBase {
this.containerDiv.id = this.divid;
this.containerDiv.style.display = this.displayClass;
this.containerDiv.innerHTML = this.origElem.innerHTML;
+ this.copyDataAttributes();
this.origElem.replaceWith(this.containerDiv);
}
createTextArea() {
@@ -69,8 +71,16 @@ class DataFile extends RunestoneBase {
if (this.numberOfCols) this.containerDiv.cols = this.numberOfCols;
this.containerDiv.innerHTML = this.origElem.innerHTML;
this.containerDiv.classList.add("datafiletextfield");
+ this.copyDataAttributes();
this.origElem.replaceWith(this.containerDiv);
}
+ copyDataAttributes() {
+ for (const attr of this.origElem.attributes) {
+ if (attr.name.startsWith("data-")) {
+ this.containerDiv.setAttribute(attr.name, attr.value);
+ }
+ }
+ }
}
/*=================================
diff --git a/bases/rsptx/interactives/runestone/datafile/test/datafile_binary.test.js b/bases/rsptx/interactives/runestone/datafile/test/datafile_binary.test.js
new file mode 100644
index 000000000..f38dcf6d2
--- /dev/null
+++ b/bases/rsptx/interactives/runestone/datafile/test/datafile_binary.test.js
@@ -0,0 +1,44 @@
+// Characterization tests for the DataFile component's handling of binary
+// files. A binary file (e.g. a compiled .jar) is registered as a hidden
+// carrying its base64 representation plus a data-isbinary attribute.
+// The component replaces that with a container that must keep those
+// attributes so LiveCode can still find them.
+import { describe, it, expect, beforeEach } from "vitest";
+// datafile.js has no exports; it registers a component_factory on window.
+import "../js/datafile.js";
+
+function makeFixture(attrs = "", content = "") {
+ document.body.innerHTML = `
+
+ ${content}
+ `;
+ return document.querySelector("[data-component=datafile]");
+}
+
+beforeEach(() => {
+ document.body.innerHTML = "";
+});
+
+describe("DataFile", () => {
+ it("preserves data-isbinary on the rendered container", () => {
+ const orig = makeFixture(
+ 'data-filename="helper.jar" data-isbinary="true" ' +
+ 'data-mime-type="application/java-archive" data-edit="false" data-hidden=""',
+ "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA==",
+ );
+ const df = window.component_factory.datafile({ orig });
+ expect(df.containerDiv.id).toBe("binary_1");
+ expect(df.containerDiv.dataset.isbinary).toBe("true");
+ expect(df.containerDiv.dataset.filename).toBe("helper.jar");
+ expect(df.containerDiv.textContent).toBe(
+ "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA==",
+ );
+ });
+
+ it("does not mark an ordinary text file as binary", () => {
+ const orig = makeFixture('data-filename="helper.txt" data-edit="false"', "hello");
+ const df = window.component_factory.datafile({ orig });
+ expect(df.isBinary).toBe(false);
+ expect(df.containerDiv.dataset.isbinary).toBeUndefined();
+ });
+});
\ No newline at end of file
diff --git a/components/rsptx/build_tools/core.py b/components/rsptx/build_tools/core.py
index a0820b3d3..34ae47982 100644
--- a/components/rsptx/build_tools/core.py
+++ b/components/rsptx/build_tools/core.py
@@ -1266,12 +1266,14 @@ def _handle_datafile(el, course_name):
filename = el.attrib.get("data-filename", el.attrib["id"])
id = el.attrib["id"]
+ is_binary = el.attrib.get("data-isbinary") == "true"
update_source_code_sync(
acid=id,
course_id=course_name,
main_code=file_contents,
filename=filename,
+ is_binary=is_binary,
)
diff --git a/components/rsptx/db/crud/rsfiles.py b/components/rsptx/db/crud/rsfiles.py
index 56c13b896..f22a8db74 100644
--- a/components/rsptx/db/crud/rsfiles.py
+++ b/components/rsptx/db/crud/rsfiles.py
@@ -12,7 +12,12 @@
# We need a synchronous version of this function for use in manifest_data_to_db
# if/when process_manifest moves to being async we could remove this
def update_source_code_sync(
- acid: str, filename: str, course_id: str, main_code: str, owner: str = None
+ acid: str,
+ filename: str,
+ course_id: str,
+ main_code: str,
+ owner: str = None,
+ is_binary: bool = False,
):
"""
Update the source code for a given acid or filename
@@ -31,6 +36,7 @@ def update_source_code_sync(
source_code_obj.filename = filename
if owner is not None:
source_code_obj.owner = owner
+ source_code_obj.is_binary = is_binary
session.add(source_code_obj)
else:
new_entry = SourceCode(
@@ -39,13 +45,19 @@ def update_source_code_sync(
course_id=course_id,
main_code=main_code,
owner=owner,
+ is_binary=is_binary,
)
session.add(new_entry)
session.commit()
async def update_source_code(
- acid: str, filename: str, course_id: str, main_code: str, owner: str = None
+ acid: str,
+ filename: str,
+ course_id: str,
+ main_code: str,
+ owner: str = None,
+ is_binary: bool = False,
):
"""
Update the source code for a given acid or filename
@@ -64,6 +76,7 @@ async def update_source_code(
source_code_obj.filename = filename
if owner is not None:
source_code_obj.owner = owner
+ source_code_obj.is_binary = is_binary
session.add(source_code_obj)
else:
new_entry = SourceCode(
@@ -72,6 +85,7 @@ async def update_source_code(
course_id=course_id,
main_code=main_code,
owner=owner,
+ is_binary=is_binary,
)
session.add(new_entry)
await session.commit()
diff --git a/components/rsptx/db/models.py b/components/rsptx/db/models.py
index ff3e52903..8e3e316bb 100644
--- a/components/rsptx/db/models.py
+++ b/components/rsptx/db/models.py
@@ -427,6 +427,10 @@ class SourceCode(Base, IdMixin):
# Filename to use when saving contents to Jobe or trying to include
# this file in a program. It is OK to reuse the same filename for different
filename = Column(String(512))
+ # True when the file contents are a base64 binary payload (e.g. a
+ # compiled .jar or .zip), which must be handed to a server (Jobe) verbatim
+ # rather than treated as source. Text files leave it False.
+ is_binary = Column(Web2PyBoolean, nullable=False)
# Owner of the datafile (username of the instructor who created it)
# Used to enforce uniqueness: filename + owner + course_id should be unique
owner = Column(String(512), index=True)
diff --git a/migrations/versions/e5f6a7b8c9d0_add_is_binary_to_source_code.py b/migrations/versions/e5f6a7b8c9d0_add_is_binary_to_source_code.py
new file mode 100644
index 000000000..6cdf22dbc
--- /dev/null
+++ b/migrations/versions/e5f6a7b8c9d0_add_is_binary_to_source_code.py
@@ -0,0 +1,40 @@
+"""add is_binary to source_code
+
+Revision ID: e5f6a7b8c9d0
+Revises: c4e8a1f7b2d9
+Create Date: 2026-08-08 12:00:00.000000
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+from rsptx.db.models import Web2PyBoolean
+
+
+# revision identifiers, used by Alembic.
+revision: str = "e5f6a7b8c9d0"
+down_revision: Union[str, None] = "c4e8a1f7b2d9"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # Add is_binary column to source_code table. True marks a base64 binary
+ # payload (e.g. a compiled .jar or .zip); text files keep it False. This
+ # lets a program on one page recognize a binary file stored for another.
+ op.add_column(
+ "source_code",
+ sa.Column(
+ "is_binary",
+ Web2PyBoolean(length=1),
+ nullable=False,
+ server_default=sa.text("'F'"),
+ ),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("source_code", "is_binary")
diff --git a/test/bases/rsptx/book_server_api/test_get_source_code.py b/test/bases/rsptx/book_server_api/test_get_source_code.py
new file mode 100644
index 000000000..707b0aefa
--- /dev/null
+++ b/test/bases/rsptx/book_server_api/test_get_source_code.py
@@ -0,0 +1,71 @@
+"""
+Functional tests for GET /logger/get_source_code returning is_binary.
+
+Binary files (e.g. a compiled .jar) are stored base64 in source_code with
+is_binary set; text files leave it False. The endpoint must surface that so
+the client knows to hand the contents to a server (Jobe) verbatim.
+"""
+
+import pytest
+
+from rsptx.db.crud import update_source_code
+
+pytestmark = pytest.mark.asyncio(loop_scope="session")
+
+COURSE = "test_course_1"
+
+
+async def test_get_source_code_returns_is_binary_for_binary(
+ auth_book_client, init_test_db
+):
+ """A binary source_code row comes back with is_binary true."""
+ await update_source_code(
+ acid="endpoint_binary_jar",
+ filename="helper.jar",
+ course_id=COURSE,
+ main_code="UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA==",
+ is_binary=True,
+ )
+ resp = await auth_book_client.get(
+ "/logger/get_source_code",
+ params={"course_id": COURSE, "acid": "endpoint_binary_jar"},
+ )
+ assert resp.status_code == 200
+ detail = resp.json()["detail"]
+ assert detail["filename"] == "helper.jar"
+ assert detail["file_contents"] == "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA=="
+ assert detail["is_binary"] is True
+
+
+async def test_get_source_code_returns_false_is_binary_for_text(
+ auth_book_client, init_test_db
+):
+ """A plain text row comes back with is_binary false."""
+ await update_source_code(
+ acid="endpoint_text_file",
+ filename="notes.txt",
+ course_id=COURSE,
+ main_code="hello world",
+ )
+ resp = await auth_book_client.get(
+ "/logger/get_source_code",
+ params={"course_id": COURSE, "acid": "endpoint_text_file"},
+ )
+ assert resp.status_code == 200
+ detail = resp.json()["detail"]
+ assert detail["filename"] == "notes.txt"
+ assert detail["is_binary"] is False
+
+
+async def test_get_source_code_missing_row_returns_null_is_binary(
+ auth_book_client, init_test_db
+):
+ """A missing row yields nulls, including is_binary."""
+ resp = await auth_book_client.get(
+ "/logger/get_source_code",
+ params={"course_id": COURSE, "acid": "endpoint_does_not_exist"},
+ )
+ assert resp.status_code == 200
+ detail = resp.json()["detail"]
+ assert detail["file_contents"] is None
+ assert detail["is_binary"] is None
diff --git a/test/components/rsptx/db/test_rsfiles.py b/test/components/rsptx/db/test_rsfiles.py
new file mode 100644
index 000000000..5497b4843
--- /dev/null
+++ b/test/components/rsptx/db/test_rsfiles.py
@@ -0,0 +1,72 @@
+"""Tests for the is_binary round-trip through the source_code crud layer.
+
+Binary files (e.g. a compiled .jar) are stored base64 in ``main_code`` with
+``is_binary`` set; text files leave it False. This covers the crud functions
+that store and fetch those rows.
+"""
+
+import pytest
+
+from rsptx.db.crud import fetch_source_code, update_source_code
+
+pytestmark = pytest.mark.asyncio(loop_scope="session")
+
+
+async def test_update_source_code_stores_is_binary(init_test_db):
+ """A binary file's is_binary flag is stored and returned."""
+ await update_source_code(
+ acid="test_binary_jar",
+ filename="helper.jar",
+ course_id="test_course_1",
+ main_code="UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA==",
+ is_binary=True,
+ )
+ row = await fetch_source_code(
+ base_course="test_course_1",
+ course_name="test_course_1",
+ acid="test_binary_jar",
+ )
+ assert row is not None
+ assert row.is_binary is True
+ assert row.filename == "helper.jar"
+
+
+async def test_update_source_code_defaults_text_to_false(init_test_db):
+ """A plain text file keeps is_binary False."""
+ await update_source_code(
+ acid="test_text_file",
+ filename="notes.txt",
+ course_id="test_course_1",
+ main_code="hello world",
+ )
+ row = await fetch_source_code(
+ base_course="test_course_1",
+ course_name="test_course_1",
+ acid="test_text_file",
+ )
+ assert row is not None
+ assert row.is_binary is False
+
+
+async def test_update_source_code_changes_is_binary_on_existing_row(init_test_db):
+ """Updating an existing row changes its is_binary flag."""
+ await update_source_code(
+ acid="test_replace_jar",
+ filename="helper.jar",
+ course_id="test_course_1",
+ main_code="first",
+ )
+ await update_source_code(
+ acid="test_replace_jar",
+ filename="helper.jar",
+ course_id="test_course_1",
+ main_code="second",
+ is_binary=True,
+ )
+ row = await fetch_source_code(
+ base_course="test_course_1",
+ course_name="test_course_1",
+ acid="test_replace_jar",
+ )
+ assert row.is_binary is True
+ assert row.main_code == "second"
diff --git a/test/migrations/test_is_binary_to_source_code.py b/test/migrations/test_is_binary_to_source_code.py
new file mode 100644
index 000000000..439292a29
--- /dev/null
+++ b/test/migrations/test_is_binary_to_source_code.py
@@ -0,0 +1,103 @@
+"""Round trip for the source_code is_binary migration (e5f6a7b8c9d0).
+
+Runs the real ``upgrade()``/``downgrade()`` bodies against the test database
+inside a transaction that is always rolled back, with a stand-in for alembic's
+``op``. Postgres DDL is transactional, so the added column disappears with the
+rollback too.
+"""
+
+import importlib.util
+import os
+import sys
+from pathlib import Path
+
+import pytest
+import sqlalchemy as sa
+
+MIGRATION = (
+ Path(__file__).resolve().parents[2]
+ / "migrations"
+ / "versions"
+ / "e5f6a7b8c9d0_add_is_binary_to_source_code.py"
+)
+
+
+@pytest.fixture
+def conn():
+ """A connection in a transaction that is never committed."""
+ url = os.environ["TEST_DBURL"]
+ engine = sa.create_engine(url, future=True)
+ connection = engine.connect()
+ try:
+ yield connection
+ finally:
+ connection.rollback()
+ connection.close()
+ engine.dispose()
+
+
+@pytest.fixture
+def migration(conn):
+ """The migration module, with ``op`` bound to the test connection."""
+ spec = importlib.util.spec_from_file_location("is_binary_mig", MIGRATION)
+ mod = importlib.util.module_from_spec(spec)
+ sys.modules["is_binary_mig"] = mod
+ spec.loader.exec_module(mod)
+
+ class FakeOp:
+ @staticmethod
+ def get_bind():
+ return conn
+
+ @staticmethod
+ def execute(stmt):
+ return conn.execute(sa.text(stmt) if isinstance(stmt, str) else stmt)
+
+ @staticmethod
+ def add_column(table, column):
+ stmt = (
+ f"ALTER TABLE {table} ADD COLUMN "
+ f"{column.name} {str(column.type)}"
+ )
+ conn.execute(sa.text(stmt))
+
+ @staticmethod
+ def drop_column(table, column):
+ name = column if isinstance(column, str) else column.name
+ conn.execute(sa.text(f"ALTER TABLE {table} DROP COLUMN {name}"))
+
+ mod.op = FakeOp
+ return mod
+
+
+def _columns(conn):
+ return {
+ r.column_name
+ for r in conn.execute(
+ sa.text(
+ "SELECT column_name FROM information_schema.columns "
+ "WHERE table_name = 'source_code'"
+ )
+ )
+ }
+
+
+def _drop_is_binary(conn):
+ """Restore the 'before migration' schema if a prior test run left it."""
+ if "is_binary" in _columns(conn):
+ conn.execute(sa.text("ALTER TABLE source_code DROP COLUMN is_binary"))
+
+
+def test_upgrade_adds_is_binary(conn, migration):
+ _drop_is_binary(conn)
+ assert "is_binary" not in _columns(conn)
+ migration.upgrade()
+ assert "is_binary" in _columns(conn)
+
+
+def test_downgrade_drops_is_binary(conn, migration):
+ _drop_is_binary(conn)
+ migration.upgrade()
+ assert "is_binary" in _columns(conn)
+ migration.downgrade()
+ assert "is_binary" not in _columns(conn)