using AIStudio.Tools.PluginSystem;
using Lua;
using Lua.Standard;
namespace AIStudio.Tests.Tools.PluginSystem;
///
/// Checks that a plugin can split its code into several Lua files and load them with require, and that
/// it reaches no other file.
///
///
/// Long plugins keep parts of their code in files of their own: the plugins AI Studio ships keep their
/// icon in icon.lua, and larger plugins arrange their modules in subdirectories. Whatever the sandbox
/// of the plugins blocks, loading these files from the plugin's own directory has to keep working.
/// Every other file stays out of reach: the Lua runtime would otherwise look for a module it does not
/// find next to the plugin in the working directory of the app, and dofile and loadfile would open any
/// path. Plugins are shared between users, and code written by a model runs in the same runtime.
///
[TestFixture]
public sealed class PluginModuleLoadingTests
{
private string testDirectory = string.Empty;
private string pluginDirectory = string.Empty;
[SetUp]
public void CreatePluginDirectory()
{
//
// The module loader only works inside a base directory, which is the plugins directory of the
// app for an installed plugin. The plugin therefore gets a base directory of its own here:
//
this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-plugin-modules-{Guid.NewGuid():N}");
this.pluginDirectory = Path.Combine(this.testDirectory, "plugin");
Directory.CreateDirectory(this.pluginDirectory);
}
[TearDown]
public void RemovePluginDirectory()
{
try
{
Directory.Delete(this.testDirectory, true);
}
catch (IOException)
{
// A temporary directory we could not remove says nothing about the code under test.
}
}
[Test]
public async Task AModuleNextToThePluginLoads()
{
this.WriteModule("helpers.lua", "return { answer = 42 }");
var answer = await this.RunAsync("""return require("helpers").answer""");
Assert.That(answer.Read(), Is.EqualTo(42));
}
[Test]
public async Task AModuleInASubdirectoryLoads()
{
this.WriteModule("lib/helpers.lua", "return { answer = 42 }");
var answer = await this.RunAsync("""return require("lib/helpers").answer""");
Assert.That(answer.Read(), Is.EqualTo(42));
}
[Test]
public async Task AModuleTwoSubdirectoriesDownLoads()
{
this.WriteModule("lib/deep/more.lua", "return { answer = 42 }");
var answer = await this.RunAsync("""return require("lib/deep/more").answer""");
Assert.That(answer.Read(), Is.EqualTo(42));
}
[Test]
public async Task AModuleLoadsAnotherModule()
{
this.WriteModule("lib/outer.lua", """
local inner = require("lib/inner")
return { answer = inner.answer + 1 }
""");
this.WriteModule("lib/inner.lua", "return { answer = 41 }");
var answer = await this.RunAsync("""return require("lib/outer").answer""");
Assert.That(answer.Read(), Is.EqualTo(42));
}
[Test]
public async Task TheGlobalsOfAModuleAreVisibleToThePlugin()
{
//
// This is how the shipped plugins use their icon.lua: it returns nothing and sets a global.
//
this.WriteModule("icon.lua", """SVG = "" """);
var icon = await this.RunAsync("""
require("icon")
return SVG
""");
Assert.That(icon.Read(), Is.EqualTo(""));
}
[TestCaseSource(nameof(ShippedPluginsWithAnIconFile))]
public async Task AShippedPluginLoadsItsIconFromItsOwnFile(string relativeDirectory)
{
//
// Only the code of the plugin runs here, not the initialization of its type: a configuration
// plugin, for instance, needs the services of the running app for that. What counts is
// that the icon arrives from icon.lua in the runtime every plugin gets.
//
var directory = Path.Combine(AppContext.BaseDirectory, relativeDirectory);
using var state = PluginFactory.CreateLuaState(directory, directory);
await state.DoStringAsync(await File.ReadAllTextAsync(Path.Combine(directory, "plugin.lua")));
Assert.That(state.Environment["ICON_SVG"].Read(), Is.EqualTo(await ReadIconAsync(directory)), "The plugin did not get its icon from its icon.lua.");
}
[Test]
public void AModuleOutsideThePluginDirectoryIsNotLoaded()
{
this.WriteModuleOutsideThePlugin();
Assert.That(async () => await this.RunAsync("""return require("../other/secret").answer"""), Throws.InstanceOf());
}
[Test]
public void AModuleNamedByItsAbsolutePathIsNotLoaded()
{
var path = Path.ChangeExtension(this.WriteModuleOutsideThePlugin(), null);
Assert.That(async () => await this.RunAsync($"return require([[{path}]]).answer"), Throws.InstanceOf());
}
[Test]
public void AModuleInTheWorkingDirectoryIsNotLoaded()
{
var moduleName = $"ai-studio-module-probe-{Guid.NewGuid():N}";
var path = Path.Combine(Environment.CurrentDirectory, $"{moduleName}.lua");
File.WriteAllText(path, "return { answer = 42 }");
try
{
Assert.That(async () => await this.RunAsync($"""return require("{moduleName}").answer"""), Throws.InstanceOf(), "A plugin with a directory found a module in the working directory.");
Assert.That(async () => await RunWithoutPluginDirectoryAsync($"""return require("{moduleName}").answer"""), Throws.InstanceOf(), "Code without a directory found a module in the working directory.");
}
finally
{
File.Delete(path);
}
}
[Test]
public void APluginCannotRedirectTheSearchForModules()
{
var directory = Path.GetDirectoryName(this.WriteModuleOutsideThePlugin());
Assert.That(async () => await this.RunAsync($"""
package.path = [[{directory}/?.lua]]
return require("secret").answer
"""), Throws.InstanceOf());
}
[Test]
public async Task APluginCannotProbeForFiles()
{
var directory = Path.GetDirectoryName(this.WriteModuleOutsideThePlugin());
var probe = $"""return package.searchpath("secret", [[{directory}/?.lua]], ".", "/")""";
//
// A runtime with the default file system finds the file. Without this counter check, a call
// which fails for another reason would pass this test without proving anything:
//
using var defaultState = LuaState.Create();
defaultState.OpenModuleLibrary();
var foundByDefault = await defaultState.DoStringAsync(probe);
Assert.That(foundByDefault[0].Type, Is.EqualTo(LuaValueType.String), "The probe itself is broken.");
var found = await this.RunAsync(probe);
Assert.That(found.Type, Is.EqualTo(LuaValueType.Nil), "The plugin learned that a file outside its directory exists.");
}
[TestCase("dofile", true)]
[TestCase("dofile", false)]
[TestCase("loadfile", true)]
[TestCase("loadfile", false)]
public void AFileCannotBeRunByItsPath(string function, bool insideThePluginDirectory)
{
//
// Even inside the plugin's directory, since such a path would be read relative to the working
// directory of the app, not to the plugin. Plugins load their own files with require instead:
//
this.WriteModule("helpers.lua", "return { answer = 42 }");
var path = insideThePluginDirectory ? Path.Combine(this.pluginDirectory, "helpers.lua") : this.WriteModuleOutsideThePlugin();
Assert.That(async () => await this.RunAsync($"local result = {function}([[{path}]])"), Throws.InstanceOf());
}
///
/// The plugins shipped with AI Studio which keep their icon in an icon.lua next to their plugin.lua.
///
private static IEnumerable ShippedPluginsWithAnIconFile() =>
Directory.EnumerateFiles(Path.Combine(AppContext.BaseDirectory, "Plugins"), "icon.lua", SearchOption.AllDirectories)
.Select(iconFile => Path.GetDirectoryName(iconFile)!)
.Where(directory => File.Exists(Path.Combine(directory, "plugin.lua")))
.Select(directory => Path.GetRelativePath(AppContext.BaseDirectory, directory))
.Order(StringComparer.Ordinal);
///
/// Reads the icon of an icon.lua on its own, without the plugin around it.
///
private static async Task ReadIconAsync(string directory)
{
using var state = LuaState.Create();
await state.DoStringAsync(await File.ReadAllTextAsync(Path.Combine(directory, "icon.lua")));
return state.Environment["SVG"].Read();
}
private void WriteModule(string relativePath, string code)
{
var path = Path.Combine(this.pluginDirectory, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, code);
}
///
/// Writes a module next to the plugin's directory, inside the same base directory.
///
/// The path of the module, with forward slashes, so it can stand in Lua code on every system.
private string WriteModuleOutsideThePlugin()
{
var path = Path.Combine(this.testDirectory, "other", "secret.lua");
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, "return { answer = 42 }");
return path.Replace('\\', '/');
}
///
/// Runs Lua code in the same runtime a plugin from the plugin directory runs in.
///
private async Task RunAsync(string code)
{
using var state = PluginFactory.CreateLuaState(this.pluginDirectory, this.testDirectory);
var results = await state.DoStringAsync(code);
return results.Length > 0 ? results[0] : LuaValue.Nil;
}
///
/// Runs Lua code in the runtime of a plugin which has no directory, such as one a model just wrote.
///
private static async Task RunWithoutPluginDirectoryAsync(string code)
{
using var state = PluginFactory.CreateLuaState(null);
var results = await state.DoStringAsync(code);
return results.Length > 0 ? results[0] : LuaValue.Nil;
}
}