// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Where the Paperless server is and how to sign in to it, read from a ZON //! file: //! //! .{ //! .url = "https://paperless.example.com", //! .token = "0123456789abcdef0123456789abcdef01234567", //! } //! //! or, to keep the token out of the configuration -- in a file a secrets //! manager writes, say -- with `token_file` in place of `token`: //! //! .{ //! .url = "https://paperless.example.com", //! .token_file = "/run/user/1000/secrets/paperless-token", //! } //! //! `$NAME` and `${NAME}` in `token_file` are replaced with that environment //! variable, since secrets managers hand out paths such as //! `/run/user/$UID/secrets/paperless-token` or //! `${XDG_RUNTIME_DIR}/agenix/paperless-token` and expect a shell to expand //! them. `UID`, which shells set without exporting, is the process's real //! user ID when the environment does not have it. A relative `token_file` is //! relative to the directory the configuration file is in. Whitespace around //! the token in it, such as a final newline, is ignored. //! //! The file is found by `locate`: a path given outright, else //! `paperless-upload/config.zon` in the user's configuration directory as //! known-folders finds it -- `$XDG_CONFIG_HOME`, else `~/.config`, on Linux. //! The environment is used only to find the files; the server and the token //! are never taken from it. const Config = @This(); const std = @import("std"); const Io = std.Io; const Allocator = std.mem.Allocator; const known_folders = @import("known_folders"); /// The server's base URL, with any path prefix it is mounted under. A /// trailing slash is removed. url: []const u8, /// An API token, from the user's profile in the Paperless web interface: /// as the configuration file gave it, or as read from its `token_file`. token: []const u8, /// The configuration file as it is written: one of `token` and /// `token_file`, and not both. pub const File = struct { url: []const u8, token: ?[]const u8 = null, /// A file holding the token and nothing else. token_file: ?[]const u8 = null, }; /// The name of the file within the configuration directory. pub const relative_path = "paperless-upload" ++ std.fs.path.sep_str ++ "config.zon"; /// What the configuration file is found from. pub const Locate = struct { /// `--config PATH`: used as it is, and nothing else is tried. explicit: ?[]const u8 = null, /// The environment the configuration directory is found from: /// `XDG_CONFIG_HOME` and `HOME`, on Linux. environ: *const std.process.Environ.Map, }; /// The path of the configuration file, allocated in `arena`, or null when /// there is neither a path given nor a configuration directory to look in. pub fn locate(io: Io, arena: Allocator, where: Locate) known_folders.Error!?[]const u8 { if (where.explicit) |path| return path; const dir = try known_folders.getPath(io, arena, where.environ, .local_configuration) orelse return null; return try std.fs.path.join(arena, &.{ dir, relative_path }); } pub const LoadError = error{ /// No path was given and there is no home directory to look in. NoConfigPath, /// The file could not be opened or read. ReadFailed, /// The file is not a valid configuration. InvalidConfig, WriteFailed, } || known_folders.Error; /// Reads and parses the configuration file, and the token file if it names /// one. Everything returned is allocated in `arena`. On failure a sentence or /// two saying what went wrong, fit to show a person, is written to `report`. /// Whichever file holds the token is warned about, in `report`, if others can /// read it; it is loaded all the same. pub fn load( io: Io, gpa: Allocator, arena: Allocator, where: Locate, report: *Io.Writer, ) LoadError!Config { const path = try locate(io, arena, where) orelse { try report.writeAll("no configuration file: there is no configuration directory (is HOME set?), and no path was given\n"); return error.NoConfigPath; }; const source = Io.Dir.cwd().readFileAllocOptions(io, path, arena, .limited(64 * 1024), .of(u8), 0) catch |err| { switch (err) { error.FileNotFound => try report.print( \\{s}: no such file; create it with \\ \\.{{ \\ .url = "https://paperless.example.com", \\ .token = "", \\}} \\ \\or with .token_file = "" in place of .token \\ , .{path}), else => try report.print("{s}: cannot read: {t}\n", .{ path, err }), } return error.ReadFailed; }; const file = try parse(gpa, arena, source, path, report); const token = if (file.token) |token| token: { try warnIfReadable(io, path, report); break :token token; } else try readTokenFile(io, arena, where.environ, path, file.token_file.?, report); return .{ .url = file.url, .token = token }; } /// The token in `token_file`, which has its variables expanded from /// `environ` and is relative to the directory of the configuration file at /// `config_path` unless it is absolute. fn readTokenFile( io: Io, arena: Allocator, environ: *const std.process.Environ.Map, config_path: []const u8, token_file: []const u8, report: *Io.Writer, ) LoadError![]const u8 { var name: []const u8 = undefined; const expanded = expand(arena, environ, token_file, &name) catch |err| { switch (err) { error.OutOfMemory => |e| return e, error.UnsetVariable => try report.print("{s}: token_file {s}: ${s} is not set\n", .{ config_path, token_file, name }), error.UnterminatedVariable => try report.print("{s}: token_file {s}: ${{ without a closing }}\n", .{ config_path, token_file }), } return error.InvalidConfig; }; const path = if (std.fs.path.isAbsolute(expanded)) expanded else try std.fs.path.join(arena, &.{ std.fs.path.dirname(config_path) orelse ".", expanded }); const contents = Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(4096)) catch |err| { try report.print("{s}: cannot read the token file {s}: {t}\n", .{ config_path, path, err }); return error.ReadFailed; }; const token = std.mem.trim(u8, contents, " \t\r\n"); if (token.len == 0) { try report.print("{s}: the token file is empty\n", .{path}); return error.InvalidConfig; } if (std.mem.findAny(u8, token, " \t\r\n") != null) { try report.print("{s}: the token file holds more than a token\n", .{path}); return error.InvalidConfig; } try warnIfReadable(io, path, report); return token; } pub const ExpandError = error{ /// A variable named in the string is not in the environment. UnsetVariable, /// A `${` has no `}` after it. UnterminatedVariable, } || Allocator.Error; /// `s` with each `$NAME` and `${NAME}` replaced by that variable from /// `environ`, allocated in `arena`. A `$` followed by neither a name nor `{` /// is kept as it is. `UID` is the process's real user ID when `environ` has /// no `UID`, since shells set it without exporting it. On /// `error.UnsetVariable` the variable's name is left in `unset`. pub fn expand( arena: Allocator, environ: *const std.process.Environ.Map, s: []const u8, unset: *[]const u8, ) ExpandError![]const u8 { if (std.mem.findScalar(u8, s, '$') == null) return s; var out: std.ArrayList(u8) = .empty; var i: usize = 0; while (i < s.len) { if (s[i] != '$' or i + 1 == s.len) { try out.append(arena, s[i]); i += 1; continue; } const name, const end = if (s[i + 1] == '{') braced: { const close = std.mem.findScalarPos(u8, s, i + 2, '}') orelse return error.UnterminatedVariable; break :braced .{ s[i + 2 .. close], close + 1 }; } else bare: { var j = i + 1; while (j < s.len and (std.ascii.isAlphanumeric(s[j]) or s[j] == '_')) j += 1; break :bare .{ s[i + 1 .. j], j }; }; if (name.len == 0 and s[i + 1] != '{') { try out.append(arena, '$'); i += 1; continue; } if (environ.get(name)) |value| { try out.appendSlice(arena, value); } else if (std.mem.eql(u8, name, "UID") and realUid() != null) { try out.print(arena, "{d}", .{realUid().?}); } else { unset.* = name; return error.UnsetVariable; } i = end; } return out.items; } /// The process's real user ID, where there is such a thing. fn realUid() ?u32 { const builtin = @import("builtin"); if (builtin.link_libc and @hasDecl(std.c, "getuid")) return @intCast(std.c.getuid()); if (builtin.os.tag == .linux) return std.os.linux.getuid(); return null; } fn warnIfReadable(io: Io, path: []const u8, report: *Io.Writer) Io.Writer.Error!void { if (groupOrWorldReadable(io, path)) { try report.print("warning: {s} holds an API token and can be read by other users; chmod 600 it\n", .{path}); } } /// Parses configuration source, reporting problems against `path`. The /// result has exactly one of `token` and `token_file`. pub fn parse( gpa: Allocator, arena: Allocator, source: [:0]const u8, path: []const u8, report: *Io.Writer, ) LoadError!File { var diagnostics: std.zon.parse.Diagnostics = undefined; var config = std.zon.parse.fromSlice(File, .{ .gpa = gpa, .arena = arena, .source = source, .diagnostics = &diagnostics, }) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.ParseZon => { try report.print("{f}", .{diagnostics.fmt(path)}); return error.InvalidConfig; }, }; config.url = std.mem.trimEnd(u8, config.url, "/"); if (!std.mem.startsWith(u8, config.url, "http://") and !std.mem.startsWith(u8, config.url, "https://")) { try report.print("{s}: url must start with http:// or https://\n", .{path}); return error.InvalidConfig; } if (config.token != null and config.token_file != null) { try report.print("{s}: give token or token_file, not both\n", .{path}); return error.InvalidConfig; } if (config.token == null and config.token_file == null) { try report.print("{s}: give the API token, as token or as token_file\n", .{path}); return error.InvalidConfig; } if (config.token) |token| if (token.len == 0) { try report.print("{s}: token is empty\n", .{path}); return error.InvalidConfig; }; if (config.token_file) |token_file| if (token_file.len == 0) { try report.print("{s}: token_file is empty\n", .{path}); return error.InvalidConfig; }; return config; } fn groupOrWorldReadable(io: Io, path: []const u8) bool { if (!@hasDecl(Io.File.Permissions, "toMode")) return false; const stat = Io.Dir.cwd().statFile(io, path, .{}) catch return false; return stat.permissions.toMode() & 0o044 != 0; } const testing = std.testing; test "parse a good file" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var report: Io.Writer.Allocating = .init(testing.allocator); defer report.deinit(); const config = try parse(testing.allocator, arena.allocator(), \\.{ \\ .url = "https://paperless.example.com/", \\ .token = "abc", \\} , "config.zon", &report.writer); try testing.expectEqualStrings("https://paperless.example.com", config.url); try testing.expectEqualStrings("abc", config.token.?); try testing.expectEqualStrings("", report.written()); } test "parse refuses both token and token_file, and neither" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var report: Io.Writer.Allocating = .init(testing.allocator); defer report.deinit(); try testing.expectError(error.InvalidConfig, parse(testing.allocator, arena.allocator(), \\.{ .url = "https://p.example.com", .token = "abc", .token_file = "t" } , "config.zon", &report.writer)); try testing.expectError(error.InvalidConfig, parse(testing.allocator, arena.allocator(), \\.{ .url = "https://p.example.com" } , "config.zon", &report.writer)); } test "load reads a token file relative to the configuration file" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var report: Io.Writer.Allocating = .init(testing.allocator); defer report.deinit(); var environ: std.process.Environ.Map = .init(testing.allocator); defer environ.deinit(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "token", .data = "abc123\n" }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "config.zon", .data = ".{ .url = \"https://p.example.com\", .token_file = \"token\" }", }); const path = try std.fs.path.join(arena.allocator(), &.{ ".zig-cache", "tmp", &tmp.sub_path, "config.zon" }); const config = try load(testing.io, testing.allocator, arena.allocator(), .{ .explicit = path, .environ = &environ }, &report.writer); try testing.expectEqualStrings("abc123", config.token); } test "load refuses a token file that is missing, empty or not a token" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var report: Io.Writer.Allocating = .init(testing.allocator); defer report.deinit(); var environ: std.process.Environ.Map = .init(testing.allocator); defer environ.deinit(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "empty", .data = "\n" }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "two", .data = "abc def\n" }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "missing.zon", .data = ".{ .url = \"https://p.example.com\", .token_file = \"nothing\" }" }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "empty.zon", .data = ".{ .url = \"https://p.example.com\", .token_file = \"empty\" }" }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "two.zon", .data = ".{ .url = \"https://p.example.com\", .token_file = \"two\" }" }); const a = arena.allocator(); const dir = try std.fs.path.join(a, &.{ ".zig-cache", "tmp", &tmp.sub_path }); try testing.expectError(error.ReadFailed, load(testing.io, testing.allocator, a, .{ .explicit = try std.fs.path.join(a, &.{ dir, "missing.zon" }), .environ = &environ }, &report.writer)); try testing.expectError(error.InvalidConfig, load(testing.io, testing.allocator, a, .{ .explicit = try std.fs.path.join(a, &.{ dir, "empty.zon" }), .environ = &environ }, &report.writer)); try testing.expectError(error.InvalidConfig, load(testing.io, testing.allocator, a, .{ .explicit = try std.fs.path.join(a, &.{ dir, "two.zon" }), .environ = &environ }, &report.writer)); } test expand { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); var environ: std.process.Environ.Map = .init(testing.allocator); defer environ.deinit(); try environ.put("XDG_RUNTIME_DIR", "/run/user/1000"); try environ.put("UID", "1000"); try environ.put("A_1", "x"); var unset: []const u8 = undefined; try testing.expectEqualStrings("/run/user/1000/secrets/t", try expand(a, &environ, "/run/user/$UID/secrets/t", &unset)); try testing.expectEqualStrings("/run/user/1000/agenix/t", try expand(a, &environ, "${XDG_RUNTIME_DIR}/agenix/t", &unset)); try testing.expectEqualStrings("x.y", try expand(a, &environ, "$A_1.y", &unset)); try testing.expectEqualStrings("xx", try expand(a, &environ, "${A_1}$A_1", &unset)); try testing.expectEqualStrings("a$/b$", try expand(a, &environ, "a$/b$", &unset)); try testing.expectEqualStrings("plain", try expand(a, &environ, "plain", &unset)); try testing.expectError(error.UnsetVariable, expand(a, &environ, "/x/$NOPE/y", &unset)); try testing.expectEqualStrings("NOPE", unset); try testing.expectError(error.UnsetVariable, expand(a, &environ, "${}", &unset)); try testing.expectError(error.UnterminatedVariable, expand(a, &environ, "${XDG_RUNTIME_DIR/t", &unset)); } test "expand takes UID from the process when the environment lacks it" { if (realUid() == null) return error.SkipZigTest; var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var environ: std.process.Environ.Map = .init(testing.allocator); defer environ.deinit(); var unset: []const u8 = undefined; const expected = try std.fmt.allocPrint(arena.allocator(), "/run/user/{d}/t", .{realUid().?}); try testing.expectEqualStrings(expected, try expand(arena.allocator(), &environ, "/run/user/$UID/t", &unset)); } test "load expands variables in token_file" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var report: Io.Writer.Allocating = .init(testing.allocator); defer report.deinit(); var environ: std.process.Environ.Map = .init(testing.allocator); defer environ.deinit(); try environ.put("NAME", "token"); try tmp.dir.writeFile(testing.io, .{ .sub_path = "token", .data = "abc123\n" }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "config.zon", .data = ".{ .url = \"https://p.example.com\", .token_file = \"${NAME}\" }", }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "unset.zon", .data = ".{ .url = \"https://p.example.com\", .token_file = \"$NOPE\" }", }); const a = arena.allocator(); const dir = try std.fs.path.join(a, &.{ ".zig-cache", "tmp", &tmp.sub_path }); const config = try load(testing.io, testing.allocator, a, .{ .explicit = try std.fs.path.join(a, &.{ dir, "config.zon" }), .environ = &environ }, &report.writer); try testing.expectEqualStrings("abc123", config.token); try testing.expectError(error.InvalidConfig, load(testing.io, testing.allocator, a, .{ .explicit = try std.fs.path.join(a, &.{ dir, "unset.zon" }), .environ = &environ }, &report.writer)); try testing.expect(std.mem.find(u8, report.written(), "$NOPE is not set") != null); } test "parse refuses a missing field" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var report: Io.Writer.Allocating = .init(testing.allocator); defer report.deinit(); try testing.expectError(error.InvalidConfig, parse(testing.allocator, arena.allocator(), \\.{ .token = "abc" } , "config.zon", &report.writer)); try testing.expect(std.mem.find(u8, report.written(), "config.zon:1:") != null); try testing.expect(std.mem.find(u8, report.written(), "url") != null); } test "parse refuses an unknown field" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var report: Io.Writer.Allocating = .init(testing.allocator); defer report.deinit(); try testing.expectError(error.InvalidConfig, parse(testing.allocator, arena.allocator(), \\.{ .url = "https://p.example.com", .token = "abc", .tokne = "x" } , "config.zon", &report.writer)); try testing.expect(std.mem.find(u8, report.written(), "tokne") != null); } test "parse refuses a url that is not http" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var report: Io.Writer.Allocating = .init(testing.allocator); defer report.deinit(); try testing.expectError(error.InvalidConfig, parse(testing.allocator, arena.allocator(), \\.{ .url = "paperless.example.com", .token = "abc" } , "config.zon", &report.writer)); } test "parse refuses an empty token" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var report: Io.Writer.Allocating = .init(testing.allocator); defer report.deinit(); try testing.expectError(error.InvalidConfig, parse(testing.allocator, arena.allocator(), \\.{ .url = "https://p.example.com", .token = "" } , "config.zon", &report.writer)); } test locate { if (@import("builtin").os.tag != .linux) return error.SkipZigTest; var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); var empty: std.process.Environ.Map = .init(testing.allocator); defer empty.deinit(); var home: std.process.Environ.Map = .init(testing.allocator); defer home.deinit(); try home.put("HOME", "/home/u"); var xdg: std.process.Environ.Map = .init(testing.allocator); defer xdg.deinit(); try xdg.put("HOME", "/home/u"); try xdg.put("XDG_CONFIG_HOME", "/xdg"); try testing.expectEqualStrings("/etc/x.zon", (try locate(testing.io, a, .{ .explicit = "/etc/x.zon", .environ = &xdg })).?); try testing.expectEqualStrings("/xdg/paperless-upload/config.zon", (try locate(testing.io, a, .{ .environ = &xdg })).?); try testing.expectEqualStrings("/home/u/.config/paperless-upload/config.zon", (try locate(testing.io, a, .{ .environ = &home })).?); try testing.expectEqual(null, try locate(testing.io, a, .{ .environ = &empty })); } test "load reports a missing file" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var report: Io.Writer.Allocating = .init(testing.allocator); defer report.deinit(); var environ: std.process.Environ.Map = .init(testing.allocator); defer environ.deinit(); try testing.expectError(error.ReadFailed, load( testing.io, testing.allocator, arena.allocator(), .{ .explicit = "/nonexistent/paperless-upload/config.zon", .environ = &environ }, &report.writer, )); try testing.expect(std.mem.find(u8, report.written(), "no such file") != null); }