main.zig (12093B)
1 const std = @import("std"); 2 const builtin = @import("builtin"); 3 4 const Entry = struct { 5 path: []const u8, 6 accessed: i128, 7 }; 8 9 pub fn main(init: std.process.Init) !void { 10 const alloc = init.gpa; 11 12 const pass_dir = try dir(alloc, init.environ_map); 13 defer alloc.free(pass_dir); 14 15 var passwords = std.ArrayListUnmanaged(Entry).empty; 16 defer { 17 for (passwords.items) |p| alloc.free(p.path); 18 passwords.deinit(alloc); 19 } 20 21 try collect(alloc, init.io, pass_dir, "", &passwords); 22 23 std.mem.sort(Entry, passwords.items, {}, struct { 24 fn lessThan(_: void, a: Entry, b: Entry) bool { 25 return a.accessed > b.accessed; 26 } 27 }.lessThan); 28 29 var paths = std.ArrayListUnmanaged([]const u8).empty; 30 defer paths.deinit(alloc); 31 for (passwords.items) |entry| { 32 try paths.append(alloc, entry.path); 33 } 34 35 const pick = try fzf(init, alloc, paths.items) orelse return; 36 defer pick.deinit(alloc); 37 38 if (std.mem.eql(u8, pick.key, "ctrl-e")) { 39 const target = if (pick.selected.len > 0) pick.selected else pick.query; 40 if (target.len == 0) return; 41 try edit(init, target); 42 return; 43 } 44 45 const selected = if (pick.selected.len > 0) pick.selected else pick.query; 46 if (selected.len == 0) return; 47 48 const exists = blk: { 49 for (passwords.items) |item| { 50 if (std.mem.eql(u8, item.path, selected)) break :blk true; 51 } 52 break :blk false; 53 }; 54 const basename_start = if (std.mem.lastIndexOfScalar(u8, selected, '/')) |idx| idx + 1 else 0; 55 const has_otp = std.mem.find(u8, selected[basename_start..], "otp") != null; 56 57 const secret = if (has_otp and exists) 58 try run(init, alloc, &.{ "pass", "otp", "code", selected }) 59 else if (!exists) 60 try store(init, alloc, selected) 61 else 62 run(init, alloc, &.{ "pass", "show", selected }) catch try store(init, alloc, selected); 63 64 try clip(init, secret); 65 alloc.free(secret); 66 } 67 68 fn dir(alloc: std.mem.Allocator, environ: *std.process.Environ.Map) ![]const u8 { 69 if (environ.get("PASSWORD_STORE_DIR")) |p| { 70 return try alloc.dupe(u8, p); 71 } 72 73 const home = environ.get("HOME") orelse return error.NoHome; 74 return try std.fs.path.join(alloc, &.{ home, ".password-store" }); 75 } 76 77 fn collect(alloc: std.mem.Allocator, io: std.Io, base_dir: []const u8, sub_path: []const u8, list: *std.ArrayListUnmanaged(Entry)) anyerror!void { 78 const full_path = try std.fs.path.join(alloc, &.{ base_dir, sub_path }); 79 defer alloc.free(full_path); 80 81 var d = std.Io.Dir.openDirAbsolute(io, full_path, .{ .iterate = true }) catch return; 82 defer d.close(io); 83 84 var it = d.iterate(); 85 while (try it.next(io)) |entry| { 86 if (entry.name.len > 0 and entry.name[0] == '.') continue; 87 88 const entry_sub_path = if (sub_path.len == 0) 89 try alloc.dupe(u8, entry.name) 90 else 91 try std.fs.path.join(alloc, &.{ sub_path, entry.name }); 92 errdefer alloc.free(entry_sub_path); 93 94 if (entry.kind == .directory) { 95 try collect(alloc, io, base_dir, entry_sub_path, list); 96 alloc.free(entry_sub_path); 97 } else if (entry.kind == .file and std.mem.endsWith(u8, entry.name, ".gpg")) { 98 const name = entry_sub_path[0 .. entry_sub_path.len - 4]; 99 const file_path = try std.fs.path.join(alloc, &.{ base_dir, entry_sub_path }); 100 defer alloc.free(file_path); 101 102 const accessed = blk: { 103 const file = std.Io.Dir.openFileAbsolute(io, file_path, .{}) catch |err| { 104 std.log.warn("failed to open {s}: {}", .{ file_path, err }); 105 break :blk @as(i128, 0); 106 }; 107 defer file.close(io); 108 109 const file_stat = file.stat(io) catch |err| { 110 std.log.warn("failed to stat {s}: {}", .{ file_path, err }); 111 break :blk @as(i128, 0); 112 }; 113 114 const atime_ts = file_stat.atime orelse break :blk @as(i128, 0); 115 break :blk @as(i128, @intCast(atime_ts.nanoseconds)); 116 }; 117 118 try list.append(alloc, Entry{ 119 .path = try alloc.dupe(u8, name), 120 .accessed = accessed, 121 }); 122 alloc.free(entry_sub_path); 123 } else { 124 alloc.free(entry_sub_path); 125 } 126 } 127 } 128 129 const Pick = struct { 130 query: []const u8, 131 key: []const u8, 132 selected: []const u8, 133 raw: []u8, 134 135 fn deinit(self: Pick, alloc: std.mem.Allocator) void { 136 alloc.free(self.raw); 137 } 138 }; 139 140 const PickParts = struct { 141 query: []const u8, 142 key: []const u8, 143 selected: []const u8, 144 }; 145 146 fn parseFzfOutput(output: []const u8) ?PickParts { 147 var lines = [3][]const u8{ "", "", "" }; 148 var it = std.mem.splitScalar(u8, output, '\n'); 149 var i: usize = 0; 150 while (i < lines.len) : (i += 1) { 151 const line = it.next() orelse break; 152 lines[i] = std.mem.trim(u8, line, " \n\r\t"); 153 } 154 155 if (lines[0].len == 0 and lines[1].len == 0 and lines[2].len == 0) return null; 156 157 return .{ .query = lines[0], .key = lines[1], .selected = lines[2] }; 158 } 159 160 fn fzf(init: std.process.Init, alloc: std.mem.Allocator, items: []const []const u8) !?Pick { 161 var child = try std.process.spawn(init.io, .{ 162 .argv = &.{ "fzf", "--exact", "--print-query", "--expect=ctrl-e" }, 163 .stdin = .pipe, 164 .stdout = .pipe, 165 .stderr = .inherit, 166 }); 167 168 if (child.stdin) |stdin| { 169 var buf: [4096]u8 = undefined; 170 var fw = stdin.writer(init.io, &buf); 171 for (items) |item| { 172 try fw.interface.writeAll(item); 173 try fw.interface.writeAll("\n"); 174 } 175 try fw.flush(); 176 stdin.close(init.io); 177 child.stdin = null; 178 } 179 180 const output = try read(init.io, alloc, &child); 181 errdefer alloc.free(output); 182 183 const term = try child.wait(init.io); 184 if (term != .exited) { 185 alloc.free(output); 186 return null; 187 } 188 189 const parts = parseFzfOutput(output) orelse { 190 alloc.free(output); 191 return null; 192 }; 193 194 return .{ 195 .query = parts.query, 196 .key = parts.key, 197 .selected = parts.selected, 198 .raw = output, 199 }; 200 } 201 202 fn edit(init: std.process.Init, target: []const u8) !void { 203 var child = try std.process.spawn(init.io, .{ 204 .argv = &.{ "pass", "edit", target }, 205 .stdin = .inherit, 206 .stdout = .inherit, 207 .stderr = .inherit, 208 }); 209 210 const term = try child.wait(init.io); 211 // `pass edit` exits non-zero with "Password unchanged." when the editor 212 // saved no changes. That is not a failure here. 213 if (term != .exited) return error.FailedExit; 214 } 215 216 test "parseFzfOutput query only" { 217 const parts = parseFzfOutput("foo\n") orelse return error.TestUnexpectedResult; 218 try std.testing.expectEqualStrings("foo", parts.query); 219 try std.testing.expectEqualStrings("", parts.key); 220 try std.testing.expectEqualStrings("", parts.selected); 221 } 222 223 test "parseFzfOutput query key selection" { 224 const parts = parseFzfOutput("foo\nctrl-e\nbar\n") orelse return error.TestUnexpectedResult; 225 try std.testing.expectEqualStrings("foo", parts.query); 226 try std.testing.expectEqualStrings("ctrl-e", parts.key); 227 try std.testing.expectEqualStrings("bar", parts.selected); 228 } 229 230 test "parseFzfOutput enter with selection" { 231 const parts = parseFzfOutput("foo\n\nbar\n") orelse return error.TestUnexpectedResult; 232 try std.testing.expectEqualStrings("foo", parts.query); 233 try std.testing.expectEqualStrings("", parts.key); 234 try std.testing.expectEqualStrings("bar", parts.selected); 235 } 236 237 test "parseFzfOutput empty query ctrl-e" { 238 const parts = parseFzfOutput("\nctrl-e\n\n") orelse return error.TestUnexpectedResult; 239 try std.testing.expectEqualStrings("", parts.query); 240 try std.testing.expectEqualStrings("ctrl-e", parts.key); 241 try std.testing.expectEqualStrings("", parts.selected); 242 } 243 244 test "parseFzfOutput empty" { 245 try std.testing.expect(parseFzfOutput("\n\n\n") == null); 246 try std.testing.expect(parseFzfOutput("") == null); 247 } 248 249 fn run(init: std.process.Init, alloc: std.mem.Allocator, args: []const []const u8) ![]u8 { 250 var child = try std.process.spawn(init.io, .{ 251 .argv = args, 252 .stdout = .pipe, 253 .stderr = .inherit, 254 }); 255 256 const output = try read(init.io, alloc, &child); 257 258 const term = try child.wait(init.io); 259 if (term != .exited or term.exited != 0) { 260 alloc.free(output); 261 return error.FailedExit; 262 } 263 264 if (std.mem.findScalar(u8, output, '\n')) |idx| { 265 const result = try alloc.dupe(u8, output[0..idx]); 266 alloc.free(output); 267 return result; 268 } 269 270 return output; 271 } 272 273 fn read(io: std.Io, alloc: std.mem.Allocator, child: *std.process.Child) ![]u8 { 274 if (child.stdout) |file| { 275 var buf: [4096]u8 = undefined; 276 var fr = file.reader(io, &buf); 277 return try fr.interface.allocRemaining(alloc, .unlimited); 278 } 279 return try alloc.dupe(u8, ""); 280 } 281 282 fn gen(init: std.process.Init, alloc: std.mem.Allocator) ![]u8 { 283 const charset = std.ascii.letters ++ "0123456789"; 284 const special_chars = "!@#$%^&*()-_=+[]{}|;:,.<>?"; 285 286 var password = try alloc.alloc(u8, 16); 287 errdefer alloc.free(password); 288 289 init.io.random(password); 290 for (password) |*c| { 291 c.* = charset[c.* % charset.len]; 292 } 293 294 var idx_bytes: [8]u8 = undefined; 295 init.io.random(&idx_bytes); 296 const pos = std.mem.readInt(u64, idx_bytes[0..8], .little) % password.len; 297 init.io.random(&idx_bytes); 298 const idx = std.mem.readInt(u64, idx_bytes[0..8], .little) % special_chars.len; 299 password[pos] = special_chars[idx]; 300 301 return password; 302 } 303 304 fn store(init: std.process.Init, alloc: std.mem.Allocator, name: []const u8) ![]u8 { 305 const password = try gen(init, alloc); 306 errdefer alloc.free(password); 307 308 inner(init, alloc, name, password) catch |err| { 309 std.log.err("failed to store password: {}", .{err}); 310 return err; 311 }; 312 return password; 313 } 314 315 fn inner(init: std.process.Init, alloc: std.mem.Allocator, name: []const u8, password: []const u8) !void { 316 const pass_dir = try dir(alloc, init.environ_map); 317 defer alloc.free(pass_dir); 318 319 const full_path = try std.fs.path.join(alloc, &.{ pass_dir, name }); 320 defer alloc.free(full_path); 321 322 if (std.fs.path.dirname(full_path)) |dir_path| { 323 std.Io.Dir.createDirAbsolute(init.io, dir_path, .default_dir) catch |err| { 324 if (err != error.PathAlreadyExists) return err; 325 }; 326 } 327 328 var child = try std.process.spawn(init.io, .{ 329 .argv = &.{ "pass", "insert", "-e", name }, 330 .stdin = .pipe, 331 .stderr = .inherit, 332 }); 333 334 if (child.stdin) |stdin| { 335 var buf: [4096]u8 = undefined; 336 var fw = stdin.writer(init.io, &buf); 337 try fw.interface.writeAll(password); 338 try fw.interface.writeAll("\n"); 339 try fw.flush(); 340 stdin.close(init.io); 341 child.stdin = null; 342 } 343 344 const term = try child.wait(init.io); 345 if (term != .exited or term.exited != 0) return error.FailedExit; 346 } 347 348 fn clip(init: std.process.Init, content: []const u8) !void { 349 const args: []const []const u8 = switch (builtin.os.tag) { 350 .macos => &.{"pbcopy"}, 351 .linux => args: { 352 if (init.environ_map.get("WAYLAND_DISPLAY")) |_| { 353 break :args &.{"wl-copy"}; 354 } else break :args &.{ "xclip", "-selection", "clipboard" }; 355 }, 356 else => return, 357 }; 358 359 var child = try std.process.spawn(init.io, .{ 360 .argv = args, 361 .stdin = .pipe, 362 }); 363 364 if (child.stdin) |stdin| { 365 var buf: [4096]u8 = undefined; 366 var fw = stdin.writer(init.io, &buf); 367 try fw.interface.writeAll(content); 368 try fw.flush(); 369 stdin.close(init.io); 370 child.stdin = null; 371 } 372 373 const term = try child.wait(init.io); 374 if (term != .exited) return error.ClipboardFailed; 375 }